From d7616899d86abb2e882c760e5c56d11b80f08a15 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 00:29:25 +0800 Subject: [PATCH] Unify dispatch operations with matched published transfer fixture --- deploy/sql/README.md | 12 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 15 +- deploy/sql/海外区域统一增量-MySQL8.sql | 385 +- .../file/service/BusinessFileCategory.java | 7 + .../openapi/dispatch-evidence-v1.yaml | 167 + .../service/DispatchEvidenceFileTest.java | 245 + .../main/resources/db/lastleg-permissions.sql | 4 +- .../db/phase5-delivery-permissions.sql | 59 +- iam/src/main/resources/openapi/iam-v1.yaml | 4 +- .../controller/IamOpenApiContractTest.java | 6 +- ...erseasPhase4PermissionSqlContractTest.java | 9 +- ...ase5DeliveryPermissionSqlContractTest.java | 40 +- ...se5DeliveryPermissionSqlExecutionTest.java | 37 +- .../AuthorizationSnapshotServiceTest.java | 10 +- lastleg-service/AGENTS.md | 18 +- lastleg-service/docs/business-index.md | 79 + ...astleg-internal-modularization-baseline.md | 4 + lastleg-service/pom.xml | 2 +- .../analyze-lastleg-dependencies.test.mjs | 34 + .../AppointmentImpactRelationMapper.java | 6 +- .../AppointmentWorkDispatchPort.java | 18 +- .../DeliveryTransferController.java | 29 +- .../MerchantDeliveryController.java | 9 - ...PlatformDeliveryFeeCoverageController.java | 14 +- .../dto/DeliveryFeeCoverageModels.java | 4 - .../delivery/dto/DeliveryRequests.java | 41 - .../delivery/dto/DeliveryResponses.java | 8 - .../delivery/entity/DeliveryFactsChanged.java | 23 + .../entity/DispatchDeliveryArrangement.java | 21 + .../delivery/entity/OutboundPalletItem.java | 8 + .../mapper/DeliveryFactsChangedMapper.java | 58 + .../mapper/DeliveryOperationMapper.java | 4 +- .../delivery/mapper/DeliveryOrderMapper.java | 12 + .../mapper/DeliverySourceEvidenceMapper.java | 12 +- .../DispatchDeliveryArrangementMapper.java | 26 + .../mapper/DispatchFeeCargoMapper.java | 2 +- .../delivery/mapper/OutboundOrderMapper.java | 8 + .../delivery/model/DeliveryCargoGroups.java | 98 + .../model/NormalDeliveryCompletionPolicy.java | 53 + .../DeliveryCancellationRepository.java | 103 + .../DeliveryDocumentRepository.java | 65 + .../DeliveryFactsChangedRepository.java | 50 + .../DispatchDeliveryRepository.java | 71 + .../service/DeliveryDocumentService.java | 79 +- .../service/DeliveryFactsChangedService.java | 118 + .../service/DeliveryFeeCoverageService.java | 122 - .../service/DeliveryTransferService.java | 461 +- .../service/DispatchCargoSourceQuery.java | 57 - .../service/DispatchDeliveryFees.java | 179 + .../service/DispatchDeliveryService.java | 354 ++ .../controller/DispatchActorFactory.java | 27 + .../DispatchAggregateController.java | 144 + .../controller/DispatchController.java | 45 +- .../controller/DispatchQueryController.java | 16 +- .../controller/SmartDispatchController.java | 172 +- .../dto/DispatchDraftGroupResult.java | 4 - .../dispatch/dto/DispatchRequests.java | 109 - .../dispatch/dto/DispatchResponse.java | 35 - .../dispatch/entity/DispatchAggregate.java | 46 + .../entity/DispatchCommandRecord.java | 8 + .../dispatch/entity/DispatchDraft.java | 4 +- .../dispatch/entity/DispatchDraftCommand.java | 17 + .../entity/DispatchDraftContainer.java | 1 + .../lastleg/dispatch/entity/DispatchItem.java | 4 + .../dispatch/entity/DispatchItemSource.java | 31 + .../dispatch/entity/DispatchOrder.java | 40 - .../dispatch/entity/DispatchPalletLock.java | 5 +- .../entity/DispatchPhysicalPalletLock.java | 25 + .../dispatch/fleet/DispatchFleetAdapter.java | 390 +- .../fleet/FleetDispatchPlanningAdapter.java | 78 +- .../fleet/FleetDispatchPlanningMapper.java | 25 +- .../gateway/DispatchEvidenceGateway.java | 15 + .../gateway/HttpDispatchEvidenceGateway.java | 96 + .../mapper/DispatchAggregateMapper.java | 105 + .../dispatch/mapper/DispatchBolMapper.java | 21 +- .../mapper/DispatchCandidateMapper.java | 55 +- .../mapper/DispatchCommandMapper.java | 24 + .../mapper/DispatchDraftClaimMapper.java | 42 + .../mapper/DispatchDraftCommandMapper.java | 21 + .../mapper/DispatchDraftContainerMapper.java | 17 + .../dispatch/mapper/DispatchDraftMapper.java | 32 +- .../mapper/DispatchExecutionReadMapper.java | 81 + .../dispatch/mapper/DispatchItemMapper.java | 3 + .../mapper/DispatchItemSourceMapper.java | 40 + .../dispatch/mapper/DispatchOrderMapper.java | 22 - .../mapper/DispatchPalletLockMapper.java | 7 - .../mapper/DispatchPhysicalClaimMapper.java | 59 + .../dispatch/mapper/DispatchReadMapper.java | 65 + .../dispatch/mapper/DispatchSourceMapper.java | 143 + .../mapper/ReturnDispatchCandidateMapper.java | 17 +- .../dispatch/model/DispatchBolData.java | 15 + .../pda/controller/DispatchPdaController.java | 75 +- .../dispatch/pda/dto/DispatchPdaRequests.java | 19 - .../pda/dto/DispatchPdaResponses.java | 43 +- .../pda/entity/DispatchPdaPickOrder.java | 1 + .../dispatch/pda/entity/DispatchPdaTask.java | 1 - .../pda/mapper/DispatchPdaPickMapper.java | 3 + .../mapper/DispatchPdaPickOrderMapper.java | 6 +- .../pda/mapper/DispatchPdaTaskMapper.java | 8 +- .../mapper/DispatchWholePdaReadMapper.java | 36 + .../service/DispatchPdaWorkflowService.java | 411 +- .../DispatchPickOrderAttributionService.java | 56 +- .../lastleg/dispatch/port/DispatchApi.java | 33 + .../DispatchAppointmentAmendmentPort.java | 24 + .../dispatch/port/DispatchCommands.java | 86 + .../dispatch/port/DispatchCompletionPort.java | 9 + .../dispatch/port/DispatchContracts.java | 101 + .../dispatch/port/DispatchDeliveryModels.java | 91 + .../dispatch/port/DispatchDeliveryPort.java | 24 + .../dispatch/port/DispatchExecutionPort.java | 28 + .../dispatch/port/DispatchPlanningModels.java | 97 + .../dispatch/port/DispatchPlanningPort.java | 39 + .../dispatch/port/DispatchResults.java | 25 + .../dispatch/port/DispatchSignalPort.java | 21 + .../lastleg/dispatch/port/DispatchTypes.java | 23 + .../lastleg/dispatch/port/DispatchViews.java | 79 + .../dispatch/port/DispatchWorkModels.java | 22 + .../DispatchAggregateRepository.java | 64 + .../repository/DispatchCommandRepository.java | 20 + .../repository/DispatchDraftRepository.java | 58 + .../DispatchExecutionRepository.java | 23 + .../DispatchItemSourceRepository.java | 30 + .../DispatchMutationRepository.java | 54 +- .../repository/DispatchPdaReadRepository.java | 27 + .../DispatchPhysicalClaimRepository.java | 35 + .../repository/DispatchPickingRepository.java | 35 + .../DispatchSourceDepartureRepository.java | 17 + .../repository/DispatchSourceRepository.java | 90 + .../service/DispatchAggregateService.java | 852 +++ .../DispatchAppointmentAllocations.java | 60 + .../service/DispatchBolPrintService.java | 28 +- .../service/DispatchBolReadService.java | 31 + .../service/DispatchCommandCodec.java | 102 + .../service/DispatchCommandHasher.java | 86 + .../service/DispatchCommandLedger.java | 94 + .../service/DispatchCompletionService.java | 68 + .../DispatchDeliveryCompletionWorker.java | 32 + .../service/DispatchDepartureService.java | 66 + .../service/DispatchDraftService.java | 479 ++ .../service/DispatchItemSourceService.java | 158 + .../service/DispatchPdaQueryService.java | 98 + .../service/DispatchPhysicalClaimService.java | 140 + .../service/DispatchPickingService.java | 140 + .../dispatch/service/DispatchService.java | 1402 ++--- .../service/ReturnRelabelDispatchStock.java | 14 +- .../ReturnCartonStockReceiptMapper.java | 6 +- .../service/ExceptionOrderService.java | 2 +- .../FleetAppointmentImpactMapper.java | 2 +- .../FleetHandoverInspectionService.java | 2 - .../exception/FleetHandoverPdaQuery.java | 12 +- .../fleet/exception/FleetTransitEvidence.java | 4 +- .../fleet/trip/FleetBookingReservations.java | 17 +- .../fleet/trip/FleetBookingService.java | 22 +- .../fleet/trip/FleetDispatchBindingGuard.java | 11 +- .../fleet/trip/FleetDispatchGateway.java | 21 +- .../trip/FleetDispatchPlanningGateway.java | 4 +- .../trip/FleetDispatchWorkflowQuery.java | 21 +- .../fleet/trip/FleetExecutionModels.java | 15 + .../fleet/trip/FleetResourceMapper.java | 3 + .../trip/FleetTripCompletionService.java | 4 +- .../lastleg/fleet/trip/FleetTripDispatch.java | 3 - .../fleet/trip/FleetTripDispatchMapper.java | 14 + .../fleet/trip/FleetTripExecutionService.java | 258 +- .../lastleg/fleet/trip/FleetTripModels.java | 4 +- .../trip/FleetTripPreparationService.java | 81 - .../fleet/trip/FleetTripRepository.java | 12 + .../lastleg/fleet/trip/FleetTripService.java | 394 +- .../PlatformFleetCapacityOfferController.java | 2 +- .../trip/PlatformFleetTripController.java | 57 +- .../fleet/trip/VehicleFulfillmentMapper.java | 2 +- .../InboundFbaAppointmentQueryMapper.java | 3 +- .../mapper/InboundPlanQueryMapper.java | 1 - .../FbaAppointmentExecutionPort.java | 2 +- .../FbaAppointmentTransportPort.java | 6 +- .../receiving/FbaReceivingService.java | 21 +- .../work/AppointmentWorkDispatchBinding.java | 169 +- .../AppointmentWorkDispatchCandidates.java | 58 +- .../work/AppointmentWorkDispatchMapper.java | 65 +- .../work/FbaAppointmentExecutionAdapter.java | 25 +- .../work/FbaAppointmentTransportAdapter.java | 77 +- .../mapper/InventoryCommandMapper.java | 4 +- .../mapper/PhysicalPalletSerialMapper.java | 22 + .../mapper/ReturnStockReceiptMapper.java | 3 +- .../port/PhysicalPalletSerialPort.java | 9 + .../PhysicalPalletSerialRepository.java | 26 + .../service/PhysicalPalletSerialService.java | 36 + .../mapper/CustomerOrderProgressMapper.java | 26 +- .../lastleg/mapper/LastlegOrderMapper.java | 2 +- .../mapper/PdaPalletOperationMapper.java | 94 + .../PdaPalletOperationRepository.java | 59 + .../service/PdaPalletOperationService.java | 202 +- .../mapper/RelabelDispatchSourceMapper.java | 69 + .../port/RelabelDispatchSourcePort.java | 45 + .../RelabelDispatchSourceRepository.java | 84 + .../service/RelabelDispatchSourceService.java | 345 ++ .../lastleg/security/OverseasPermissions.java | 9 +- .../src/main/resources/db/lastleg.sql | 268 +- .../openapi/fleet-preparation-v1.yaml | 42 +- .../resources/openapi/fleet-trips-v1.yaml | 102 +- .../openapi/overseas-business-v1.yaml | 4768 +++++++++++++++-- .../openapi/overseas-pda-operations-v1.yaml | 323 +- .../AppointmentImpactRelationTest.java | 13 +- .../Phase4BrowserApiBoundaryTest.java | 2 +- .../delivery/DeliveryCargoGroupsTest.java | 30 + .../delivery/DeliveryDispatchFixtures.java | 37 + .../DeliveryFactsChangedPersistenceTest.java | 100 + .../DeliveryFeeCoverageServiceTest.java | 112 - .../DeliverySourceEvidenceMapperTest.java | 23 +- ...iverySourcePublicationPersistenceTest.java | 22 +- ...tchDeliveryArrangementPersistenceTest.java | 180 + .../delivery/DispatchDeliveryFeesTest.java | 143 + .../delivery/DispatchDeliveryServiceTest.java | 157 + .../delivery/DispatchFeeCargoMapperTest.java | 42 +- .../NormalDeliveryCompletionPolicyTest.java | 47 + .../PlatformDispatchOutboundContractTest.java | 69 +- ...urceDeliveryCompletionConcurrencyTest.java | 9 +- .../MerchantDeliveryControllerTest.java | 105 +- .../service/DeliveryDocumentServiceTest.java | 13 +- .../service/DeliveryTransferServiceTest.java | 455 +- .../service/ReturnOutboundClosureTest.java | 264 +- .../DispatchCandidateMapperContractTest.java | 5 +- .../dispatch/DispatchPhysicalWeightTest.java | 6 +- .../dispatch/DispatchSchemaContractTest.java | 31 +- .../SmartDispatchOpenApiContractTest.java | 90 +- .../DispatchQueryControllerTest.java | 19 +- .../SmartDispatchControllerTest.java | 165 +- .../FleetDispatchPlanningAdapterTest.java | 51 +- .../FleetPlanningReadPersistenceTest.java | 5 +- .../HttpDispatchEvidenceGatewayTest.java | 68 + .../DispatchPdaWorkflowServiceTest.java | 404 +- ...spatchPickOrderAttributionServiceTest.java | 18 +- .../port/DispatchPortCompileContract.java | 75 + .../port/DispatchPublicContractTest.java | 12 + .../port/DispatchPublicJsonContract.java | 104 + .../DispatchAggregatePersistenceTest.java | 358 ++ .../DispatchAppointmentAllocationsTest.java | 45 + ...ppointmentCancellationPersistenceTest.java | 86 +- .../service/DispatchBolPrintServiceTest.java | 46 +- .../DispatchBolReadPersistenceTest.java | 41 + .../DispatchCommandLedgerPersistenceTest.java | 115 + .../DispatchCompletionPersistenceTest.java | 66 + .../DispatchDeliveryCompletionWorkerTest.java | 23 + .../DispatchDeparturePersistenceTest.java | 92 + .../service/DispatchDraftPersistenceTest.java | 580 ++ .../DispatchDraftResourceLockTest.java | 51 + .../DispatchItemSourcePersistenceTest.java | 430 ++ .../DispatchMergedRelabelPersistenceTest.java | 221 + .../DispatchPdaReadPersistenceTest.java | 37 + .../DispatchPhysicalClaimPersistenceTest.java | 187 + .../DispatchPickingPersistenceTest.java | 105 + .../service/DispatchRepairWorkflowTest.java | 342 +- .../dispatch/service/DispatchServiceTest.java | 907 +--- .../DispatchWholeSourcePersistenceTest.java | 82 + ...eturnDispatchCandidatePersistenceTest.java | 2 +- ...xceptionOrderDispatchReturnSourceTest.java | 21 +- .../ReturnCartonReturnPersistenceTest.java | 32 +- .../exception/FleetHandoverPdaQueryTest.java | 14 +- .../FleetHandoverTransactionTest.java | 3 +- .../exception/FleetTransitEvidenceTest.java | 71 +- .../fleet/pickup/FleetPickupServiceTest.java | 4 +- .../FleetDispatchRoutePersistenceTest.java | 98 + .../trip/FleetDispatchWorkflowQueryTest.java | 44 +- .../fleet/trip/FleetTripApiContractTest.java | 3 +- .../trip/FleetTripAuthorizationTest.java | 26 +- .../fleet/trip/FleetTripPersistenceTest.java | 1320 +---- .../trip/VehicleFulfillmentQueryTest.java | 16 +- ...oundPlanDispatchStatusPersistenceTest.java | 29 +- .../InboundPlanQueryMapperContractTest.java | 3 +- ...mentWorkPreArrangementPersistenceTest.java | 19 +- .../FbaAppointmentPersistenceTest.java | 10 +- ...AppointmentRepartitionPersistenceTest.java | 2 +- .../service/FbaReceivingPersistenceTest.java | 49 +- .../service/InboundPlanPaginationTest.java | 3 +- .../AppointmentWorkDispatchBindingTest.java | 47 +- ...ppointmentWorkDispatchPersistenceTest.java | 143 + .../work/AppointmentWorkDomainMapperTest.java | 17 +- .../FbaAppointmentExecutionAdapterTest.java | 41 +- .../FbaAppointmentTransportAdapterTest.java | 119 +- .../DispatchShipmentSourceQueryTest.java | 25 +- ...ventoryHoldConcurrencyPersistenceTest.java | 2 +- .../PhysicalPalletSerialPersistenceTest.java | 382 ++ .../CustomerOrderProgressMapperTest.java | 30 +- .../mapper/SharedDispatchReturnQueryTest.java | 87 + ...daPalletDispatchFreezePersistenceTest.java | 539 ++ .../PdaPalletOperationServiceTest.java | 38 +- .../pda/PdaOperationsOpenApiContractTest.java | 15 +- .../pda/PdaOutboundDispatchContractTest.java | 15 +- .../RelabelDispatchSourcePersistenceTest.java | 553 ++ .../lastleg-slice-dependencies.json | 35 + scripts/README.md | 17 +- scripts/admin-test-env-modules/README.md | 12 +- ...g-reservation-acceptance-contract.test.mjs | 28 +- .../fleet-booking-reservation-acceptance.mjs | 21 +- ...eet-capacity-pool-schema-contract.test.mjs | 4 +- ...ross-customer-acceptance-contract.test.mjs | 157 +- .../fleet-cross-customer-acceptance.mjs | 326 +- ...t-cross-customer-fixture-contract.test.mjs | 112 +- .../fleet-cross-customer-fixture.mjs | 119 +- ...rnal-dispatch-acceptance-contract.test.mjs | 270 +- .../fleet-external-dispatch-acceptance.mjs | 352 +- ...-fee-coverage-acceptance-contract.test.mjs | 208 +- .../fleet-fee-coverage-acceptance.mjs | 365 +- ...leet-handover-acceptance-contract.test.mjs | 113 +- .../fleet-handover-acceptance.mjs | 142 +- ...rn-redispatch-acceptance-contract.test.mjs | 467 +- .../fleet-return-redispatch-acceptance.mjs | 178 +- ...eet-trip-cost-acceptance-contract.test.mjs | 30 +- .../fleet-trip-cost-acceptance.mjs | 13 +- ...-trip-finance-acceptance-contract.test.mjs | 15 +- .../fleet-trip-fulfillment-contract.test.mjs | 528 +- .../fleet-trip-fulfillment.mjs | 534 +- ...uped-dispatch-acceptance-contract.test.mjs | 207 +- .../grouped-dispatch-acceptance.mjs | 379 +- .../initialization-baseline-contract.test.mjs | 56 +- ...merged-overseas-coverage-contract.test.mjs | 12 +- ...phase4-overseas-dispatch-contract.test.mjs | 339 +- .../phase4-overseas-dispatch.mjs | 607 +-- ...se5-overseas-fulfillment-contract.test.mjs | 265 +- .../phase5-overseas-fulfillment.mjs | 629 ++- .../phase5-root-fulfillment-contract.test.mjs | 191 +- .../phase5-root-fulfillment.mjs | 244 +- scripts/agents-index-contract.ps1 | 27 + scripts/fleet-capacity-pool-test-env.mjs | 125 +- scripts/fleet-capacity-pool-test-env.test.mjs | 57 +- ...eet-capacity-pool-ui-fixture-gray.test.mjs | 14 +- ...leet-capacity-pool-ui-fixture-test-env.mjs | 58 +- ...capacity-pool-ui-fixture-test-env.test.mjs | 33 +- scripts/fleet-cross-customer-test-env.mjs | 107 +- .../fleet-cross-customer-test-env.test.mjs | 97 +- scripts/fleet-same-route-cost-test-env.mjs | 322 +- .../fleet-same-route-cost-test-env.test.mjs | 199 +- scripts/fleet-test-env-acceptance.mjs | 680 ++- scripts/fleet-test-env-acceptance.test.mjs | 395 +- scripts/fleet-trip-rollback-test-env.mjs | 480 +- scripts/fleet-trip-rollback-test-env.test.mjs | 388 +- scripts/java-quality-baseline.json | 2 +- scripts/java-quality-summary.test.mjs | 4 +- scripts/mixed-container-outbound-test-env.mjs | 475 +- ...native-retained-outbound-contract.test.mjs | 175 + ...verseas-relabel-fulfillment-acceptance.mjs | 246 +- scripts/overseas-relabel-real-acceptance.mjs | 684 ++- .../overseas-relabel-real-contract.test.mjs | 289 + scripts/prod-release-overseas-stage.mjs | 270 +- scripts/prod-release-overseas-stage.test.mjs | 246 +- scripts/synchronous-dependency-baseline.mjs | 3 + .../synchronous-dependency-contract.test.mjs | 2 +- 346 files changed, 29244 insertions(+), 12579 deletions(-) create mode 100644 file/src/main/resources/openapi/dispatch-evidence-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java delete 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/DispatchAggregate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java delete 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/DispatchPhysicalPalletLock.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java delete 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/DispatchPhysicalClaimMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java delete 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/mapper/DispatchWholePdaReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/PhysicalPalletSerialMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/port/PhysicalPalletSerialPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/repository/PhysicalPalletSerialRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryCargoGroupsTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFactsChangedPersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/NormalDeliveryCompletionPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedgerPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorkerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/mapper/SharedDispatchReturnQueryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java create mode 100644 scripts/native-retained-outbound-contract.test.mjs create mode 100644 scripts/overseas-relabel-real-contract.test.mjs diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 61905f623..fdd0db248 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,9 @@ # 单测试环境数据库空库初始化 +排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;`ll_dispatch_physical_pallet_lock` 独立保存无代表客户的实托排他占用,`ll_dispatch_pallet_lock.physical_lock_id` 关联逐客户逻辑来源,同车可共享、跨主单或草稿容器仍排他。`ll_dispatch_item` 按主单和完整来源键唯一,`ll_dispatch_item_source` 冻结换标明细的实际位置、绑定箱数、真实件数(未知为空)及原货源事实;拣货归属新增 `dispatch_item_source_id`,与实收、退回实收、原生退货箱三类凭据四者互斥,不伪造普通收货凭据。智能草稿按拥有者、起运仓和版本维护,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。 + +`ll_physical_pallet_guard` 以真实全局实托ID和首次创建时间提供排车冻结与仓内改托的共同串行点,独立于运输占用,不保存客户、状态、有效期或第三种拥有者。`ll_dispatch_item_source.relabel_round_key`保存真实作业轮次,不能把扫码引起的库存和申请状态变化误当作换标轮次变化。排车、草稿和仓内调用必须使用匹配的公共锁实现;本条只说明空库基线结构,不表示已执行或已部署。 + 本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。 `migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。 @@ -47,7 +51,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 220 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -57,7 +61,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving - 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。 - 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。 - 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。 -- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;拣货`receipt_id`与`return_receipt_id`恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。 +- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;`receipt_id`、`return_receipt_id`、`return_carton_id`、`dispatch_item_source_id` 四类拣货归属凭据恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -70,7 +74,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/215`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -86,7 +90,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、419 个权限、600 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、454 个权限、618 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d58001da3..18f36e6b5 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6356,10 +6356,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6381,6 +6380,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create','新增排车单',NULL,'POST',765,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete','确认装车完成',NULL,'POST',766,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart','确认实际发车',NULL,'POST',767,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6458,7 +6460,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view','资金账户','/finance/account','GET',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create','提交充值申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6737,7 +6738,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107013,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107014,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107016,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107017,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107018,0,CURRENT_TIMESTAMP(3)); @@ -6761,6 +6761,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107062,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107063,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107065,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107066,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107067,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107070,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107071,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107072,0,CURRENT_TIMESTAMP(3)); @@ -7038,6 +7041,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107050,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107060,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107065,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107066,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107067,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107070,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107071,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107072,0,CURRENT_TIMESTAMP(3)); @@ -7121,7 +7127,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305020,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305021,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306000,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306001,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306100,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306101,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306110,0,CURRENT_TIMESTAMP(3)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d4a6b28a1..b821e6b70 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -325,11 +325,13 @@ CREATE TABLE ll_destuff_pallet_cost_allocation ( CREATE TABLE ll_dispatch_draft ( id bigint NOT NULL COMMENT '主键ID', draft_token varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能排车草稿随机标识', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户租户ID', - operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿操作人ID', - plan_ids_json varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所选入仓计划ID列表JSON', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿拥有者身份,仅本人可维护及恢复命令', + warehouse_id BIGINT NOT NULL COMMENT '本草稿唯一起运公共仓库引用', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '草稿并发版本,显式零版本合法', + sources_json LONGTEXT NOT NULL COMMENT '服务端核验的真实来源及客户版本快照数组', + plan_ids_json longtext COLLATE utf8mb4_unicode_ci COMMENT '所选入仓计划ID列表JSON,容纳最多五百个完整长整型引用', containers_json longtext COLLATE utf8mb4_unicode_ci COMMENT '草稿装车布局JSON快照', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态:ACTIVE有效、RELEASED释放、CONVERTED已整批转换', expires_at datetime(3) DEFAULT NULL COMMENT '草稿失效时间', released_at datetime(3) DEFAULT NULL COMMENT '草稿释放时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -345,108 +347,215 @@ CREATE TABLE ll_dispatch_draft_container ( draft_token varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能排车草稿随机标识', sequence_no int NOT NULL COMMENT '草稿车厢顺序号', capacity int NOT NULL COMMENT '草稿车厢托盘容量', + source_keys_json LONGTEXT 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_dispatch_draft_container_sequence (draft_id,sequence_no), KEY idx_ll_dispatch_draft_container_token (draft_token) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车托盘'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车容器,每个非空容器转换一张整车单'; + +CREATE TABLE ll_dispatch_draft_command ( + id BIGINT NOT NULL COMMENT '草稿命令内部主键', + idempotency_key VARCHAR(128) NOT NULL COMMENT '草稿操作全局唯一幂等键', + operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作主体,恢复必须为本人', + action VARCHAR(32) NOT NULL COMMENT '草稿创建、维护、选货、释放或转换动作', + target_token VARCHAR(128) NOT NULL COMMENT '命令目标草稿标识或创建范围标识', + request_hash CHAR(64) NOT NULL COMMENT '原动作、主体、版本及完整载荷摘要', + result_json LONGTEXT NULL COMMENT '本事务完成后保存的不可变原结果,不提交空结果', + created_at DATETIME(3) NOT NULL COMMENT '命令创建UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_draft_command_key (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿持久命令及原结果'; CREATE TABLE ll_dispatch_item ( - id bigint NOT NULL COMMENT '主键ID', - dispatch_order_id bigint NOT NULL COMMENT '派车单ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - plan_id bigint DEFAULT NULL COMMENT '入仓计划ID', - plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', - warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', - order_id bigint DEFAULT NULL COMMENT '订单ID', - order_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '订单号', - order_refs varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联订单信息', - inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓单号快照', - pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘号', - overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', - overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', - destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID', - destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', - destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', - fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', - destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', - delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', - postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', - contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', - contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', - package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + customer_name VARCHAR(255) NULL COMMENT '本条真实客户名称快照', + source_key VARCHAR(255) NOT NULL COMMENT '本条货物稳定权威来源键', + source_version CHAR(64) NOT NULL COMMENT '选货时核对的真实来源版本摘要', + source_snapshot_json JSON NOT NULL COMMENT '本条货物不可变来源及关联事实快照', + 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(128) 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仓库编码', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),未知为NULL,不以材积重代替', - volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', - volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', - source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段', - carton_marks varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标箱唛映射快照', - source_business_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型', - source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', + carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照', + source_business_type VARCHAR(32) NULL COMMENT '来源业务类型', + source_business_no VARCHAR(128) NULL COMMENT '来源业务单号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), - UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,source_key), 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车货物冻结明细'; CREATE TABLE ll_dispatch_order ( - id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派车单号', - overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', - overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', - vehicle_plate varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车牌号', - driver_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机姓名', - driver_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机电话', - carrier_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运商名称', - route varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '运输路线', - depart_at datetime(3) DEFAULT NULL COMMENT '发车时间', - fba_appointment_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA预约号', - fba_appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', - vehicle_appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', - estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', - status varchar(32) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '部分完成原因', - created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT 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 '更新时间', + id BIGINT NOT NULL COMMENT '整车排车主单内部主键', + dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', + trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '起运海外仓名称快照', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '主单生命周期:ACTIVE有效、CANCELLED已取消', + stage VARCHAR(32) NOT NULL DEFAULT 'BEFORE_LOADING' COMMENT '作业阶段:BEFORE_LOADING装车前、LOADING装车中、AFTER_LOADING装车后、DELIVERING派送中、DELIVERED派送完成', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '整车公共并发版本,首版为零', + cargo_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本趟货物修订版本', + cargo_hash CHAR(64) NOT NULL COMMENT '当前整车真实货物及来源摘要', + review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', + review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次', + review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照', + loading_review_json JSON NULL COMMENT '当前理货审核事实快照', + resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', + vehicle_plate VARCHAR(64) NULL COMMENT '本趟车辆真实车牌快照', + route VARCHAR(2000) NULL COMMENT '真实运输路线展示快照', + planned_departure_at DATETIME(3) NULL COMMENT '计划发车时间,草稿未知时为空', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + loading_completed_at DATETIME(3) NULL COMMENT '实际确认装车完成时间', + loading_completion_hash CHAR(64) NULL COMMENT '实际装车完成事实摘要', + loading_confirmation_json JSON NULL COMMENT '装车确认时逐实托真实实载完整快照', + arrangement_key VARCHAR(128) NULL COMMENT '本车唯一派送安排事实标识', + arrangement_business_hash CHAR(64) NULL COMMENT '本车派送安排业务内容摘要', + departed_at DATETIME(3) NULL COMMENT '本趟实际发车时间', + delivery_completed_at DATETIME(3) NULL 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 '本车实际发运箱数', + remark VARCHAR(2000) 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 (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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程派车单'; + UNIQUE KEY uk_dispatch_trip (trip_id), + KEY idx_ll_dispatch_order_list (overseas_warehouse_id,status,stage,created_at,id), + KEY idx_ll_dispatch_order_vehicle (vehicle_plate,status,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台整车排车作业主单,客户归属在真实明细'; + +CREATE TABLE ll_dispatch_history ( + id BIGINT NOT NULL COMMENT '排车历史内部主键', + dispatch_id BIGINT NOT NULL COMMENT '本次历史所属整车主单ID', + record_version BIGINT NOT NULL COMMENT '操作完成后的整车公共版本', + action VARCHAR(40) NOT NULL COMMENT '本次真实业务动作', + operator_id VARCHAR(128) NOT NULL COMMENT '可信操作主体ID', + reason VARCHAR(2000) NULL COMMENT '审核驳回或变更原因', + snapshot_json JSON NOT NULL COMMENT '动作完成当时的完整主单事实快照', + occurred_at DATETIME(3) NOT NULL COMMENT '真实操作发生时间', + PRIMARY KEY (id), + KEY idx_dispatch_history_version (dispatch_id,record_version,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车版本与审核轮次的追加历史'; + +CREATE TABLE ll_dispatch_item_source ( + id BIGINT NOT NULL COMMENT '逻辑运输来源贡献快照主键', + dispatch_order_id BIGINT NOT NULL COMMENT '所属整车排车主单ID', + dispatch_item_id BIGINT NOT NULL COMMENT '所属客户逻辑排车明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '本条真实客户ID', + relabel_application_id BIGINT NOT NULL COMMENT '换标申请ID', + relabel_item_id BIGINT NOT NULL COMMENT '换标申请原明细ID', + relabel_round_key VARCHAR(128) NOT NULL COMMENT '冻结的真实换标作业轮次,扫码数量变化不改变轮次', + warehouse_pallet_id BIGINT NOT NULL COMMENT '当前真实物理托盘ID', + inventory_position_id BIGINT NOT NULL COMMENT '绑定后实际库存位置ID', + inventory_balance_id BIGINT NOT NULL COMMENT '绑定后实际库存余额ID', + source_inventory_balance_id BIGINT NULL COMMENT '原货源库存余额快照,未知为空', + source_inbound_order_id BIGINT NULL COMMENT '逐换标明细原货源订单ID,未知为空', + source_inbound_order_no VARCHAR(128) NULL COMMENT '逐换标明细原货源订单业务号快照', + source_inbound_code VARCHAR(128) NULL COMMENT '逐换标明细原入仓业务号快照', + notified_package_count BIGINT NOT NULL COMMENT '本来源通知箱数,不代表全部换标成功', + package_count BIGINT NOT NULL COMMENT '本来源实际绑定的可拣箱数', + product_quantity BIGINT NULL COMMENT '有真实依据的来源产品件数,未知为空', + binding_basis_hash CHAR(64) NOT NULL COMMENT '申请轮次、位置及逐来源贡献的冻结依据摘要', + unit_facts_json JSON NOT NULL COMMENT '逐件新旧箱唛及成功或异常工作事实快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_item_source_contribution (dispatch_item_id,relabel_item_id,inventory_position_id), + KEY idx_dispatch_item_source_dispatch (dispatch_order_id,dispatch_item_id), + KEY idx_dispatch_item_source_relabel (customer_id,relabel_application_id,relabel_item_id), + CONSTRAINT chk_dispatch_item_source_packages CHECK(notified_package_count>=0 AND package_count>0), + CONSTRAINT chk_dispatch_item_source_quantity CHECK(product_quantity IS NULL OR product_quantity>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标逻辑来源实际贡献不可变快照'; + +CREATE TABLE ll_physical_pallet_guard ( + warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间', + PRIMARY KEY (warehouse_pallet_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车冻结与仓内实托组成修改的共同串行点'; + +CREATE TABLE ll_dispatch_physical_pallet_lock ( + id BIGINT NOT NULL COMMENT '物理实托占用主键', + warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID', + active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空', + dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥', + draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥', + draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID', + operator_id VARCHAR(128) NULL COMMENT '占用操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态', + expires_at DATETIME(3) NULL 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_dispatch_physical_active_pallet (active_warehouse_pallet_id), + KEY idx_dispatch_physical_order (dispatch_order_id,status), + KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status), + CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)), + CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='同车共享且跨车排他的物理实托占用'; CREATE TABLE ll_dispatch_pallet_lock ( - id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - source_pallet_key varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘业务键', - active_key varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的资源排他键', - plan_id bigint DEFAULT NULL COMMENT '入仓计划ID', - plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', - warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', - dispatch_order_id bigint DEFAULT NULL COMMENT '正式派车单ID,草稿锁阶段为空', - draft_container_id bigint DEFAULT NULL COMMENT '智能排车草稿装车托盘ID', - draft_token varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '智能排车草稿随机标识', - operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿锁操作人ID', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', - locked_at datetime(3) NOT NULL COMMENT '锁定时间', - expires_at datetime(3) DEFAULT 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 '更新时间', + id BIGINT NOT NULL COMMENT '主键ID', + physical_lock_id BIGINT 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 NULL COMMENT '正式派车单ID,草稿锁阶段为空', + draft_container_id BIGINT NULL COMMENT '智能排车草稿装车托盘ID', + draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识', + operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + expires_at DATETIME(3) 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 '更新时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), + KEY idx_dispatch_logical_physical (physical_lock_id,status), 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车托盘排他锁'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户逻辑运输来源占用'; CREATE TABLE ll_dispatch_pda_operation ( id bigint NOT NULL COMMENT '主键ID', @@ -505,27 +614,24 @@ CREATE TABLE ll_return_stock_receipt ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; CREATE TABLE ll_dispatch_pda_pick_order ( - id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - pick_id bigint NOT NULL COMMENT 'PDA拣货记录ID', - receipt_id BIGINT 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 '创建时间', + 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 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 '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥', + dispatch_item_source_id BIGINT NULL COMMENT '换标实际来源贡献快照ID,与其他三类真实凭据互斥', KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), - CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1), - PRIMARY KEY (id), - KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), + KEY idx_dispatch_pick_item_source(customer_id,dispatch_item_source_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) + (dispatch_item_source_id IS NOT NULL) = 1), + + + 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA拣货订单归属记录'; CREATE TABLE ll_dispatch_pda_task ( id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - dispatch_order_id bigint NOT NULL COMMENT '派车单ID', + dispatch_order_id bigint NOT NULL COMMENT '唯一整车排车单引用,客户归属保留在扫码明细', task_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA任务号', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务状态', operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', @@ -536,9 +642,9 @@ CREATE TABLE ll_dispatch_pda_task ( 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_dispatch (dispatch_order_id), UNIQUE KEY uk_ll_dispatch_pda_task_no (task_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA拣货任务'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车PDA拣货任务,一张排车单仅一条任务'; CREATE TABLE ll_dropship_carton ( id bigint NOT NULL COMMENT '主键ID', @@ -1827,7 +1933,71 @@ CREATE TABLE ll_outbound_order ( KEY idx_ll_outbound_list (customer_id,status,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程出库单'; +CREATE TABLE ll_dispatch_command ( + id BIGINT NOT NULL COMMENT '持久命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '命令所属整车排车主单ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '命令所属真实排车业务号', + action VARCHAR(40) NOT NULL COMMENT '本次整车作业动作', + operator_id VARCHAR(128) NOT NULL COMMENT '原始可信操作主体ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '跨操作主体全局唯一的原始请求键', + record_version BIGINT NOT NULL COMMENT '原请求明确提交的排车版本,零为合法首版', + command_hash CHAR(64) NOT NULL COMMENT '绑定原主体动作业务号版本和完整载荷的严格摘要', + result_json LONGTEXT NOT NULL COMMENT '按动作类型恢复的不可变成功结果JSON', + created_at DATETIME(3) NOT NULL COMMENT '成功命令记录时间,使用UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_command_key (idempotency_key), + KEY idx_dispatch_command_history (dispatch_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车公共持久命令账本'; + +CREATE TABLE ll_dispatch_delivery_arrangement ( + id BIGINT NOT NULL COMMENT '安排事实内部主键', + dispatch_order_id BIGINT NOT NULL COMMENT '唯一整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + arrangement_key VARCHAR(128) NOT NULL COMMENT '本次唯一安排业务事实标识', + business_hash CHAR(64) NOT NULL COMMENT '排除操作者及纯并发版本的安排业务摘要', + cargo_hash CHAR(64) NOT NULL COMMENT '本车实际拣货来源摘要', + loading_completion_hash CHAR(64) NOT NULL COMMENT '装车完成事实摘要', + original_command_key VARCHAR(255) NOT NULL COMMENT '首次安排的全局请求键', + original_operator_id VARCHAR(128) NOT NULL COMMENT '首次安排操作主体', + result_json JSON NOT NULL COMMENT '全部客户出库及客户目的地派送不可变结果', + cargo_json JSON NOT NULL COMMENT '逐客户逐拣货归属的不可变真实来源', + created_at DATETIME(3) NOT NULL COMMENT '本次安排实际创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_arrangement_dispatch (dispatch_order_id), + UNIQUE KEY uk_dispatch_arrangement_key (arrangement_key), + UNIQUE KEY uk_dispatch_arrangement_command (original_command_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车唯一派送安排事实'; + +CREATE TABLE ll_delivery_facts_changed ( + id BIGINT NOT NULL COMMENT '持久信号内部主键', + customer_id VARCHAR(128) NOT NULL COMMENT '真实签收货物客户ID', + dispatch_id BIGINT NOT NULL COMMENT '整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + delivery_id BIGINT NOT NULL COMMENT '本客户派送单ID', + delivery_no VARCHAR(128) NOT NULL COMMENT '真实派送业务号', + delivery_version BIGINT NOT NULL COMMENT '触发信号的真实派送版本', + facts_hash CHAR(64) NOT NULL COMMENT '该版本真实签收事实摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '签收事实实际发生时间', + state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '待核对PENDING或已核对PROCESSED', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已记录的汇总失败次数', + next_attempt_at DATETIME(3) NOT NULL COMMENT '下次可重试时间', + last_error_code VARCHAR(64) NULL COMMENT '最近失败诊断码不保存敏感异常正文', + processed_at DATETIME(3) NULL COMMENT '与主阶段汇总同事务确认时间', + PRIMARY KEY (id), + UNIQUE KEY uk_delivery_fact_version (customer_id,delivery_id,delivery_version), + KEY idx_delivery_fact_due (state,next_attempt_at,id), + KEY idx_delivery_fact_dispatch (dispatch_id,state,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正常签收事实变化的本地持久待汇总信号'; + CREATE TABLE ll_outbound_pallet_item ( + return_sku_quantity BIGINT NULL COMMENT '本来源退货出库SKU件数,空表示整箱出库', + dispatch_item_id BIGINT NULL COMMENT '本次整车排车作业明细ID', + source_attribution_id BIGINT NULL COMMENT '不可变PDA真实拣货归属ID', + source_order_id BIGINT NULL COMMENT '本行真实来源尾程订单ID', + source_inventory_position_id BIGINT NULL COMMENT '本行实际拣货库存仓位ID', + source_return_receipt_id BIGINT NULL COMMENT '本行实际退回再派实收记录ID', + source_return_carton_id BIGINT NULL COMMENT '本行实际退货换标来源箱ID', + carton_marks VARCHAR(4000) NULL COMMENT '本行真实拣货箱唛快照', id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', outbound_order_id bigint NOT NULL COMMENT '出库单ID', @@ -1848,6 +2018,7 @@ CREATE TABLE ll_outbound_pallet_item ( 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,inbound_code,sku_code), + UNIQUE KEY uk_outbound_source_attribution (customer_id,source_attribution_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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出库托盘明细'; @@ -2946,8 +3117,8 @@ CREATE TABLE ll_fleet_trip ( max_pallet_spaces INT NULL COMMENT '分配时托位容量快照', max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克', max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米', - planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC', - expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + planned_departure_at DATETIME(3) NULL COMMENT '计划发车UTC,空单草稿可未填写,提交审核前核齐', + expected_driver_end_at DATETIME(3) NULL COMMENT '司机预计任务结束UTC,含返程转场,草稿可未填写', departed_at DATETIME(3) NULL COMMENT '实际发车UTC', completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC', stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点', @@ -3119,20 +3290,18 @@ CREATE TABLE ll_fleet_resource_assignment ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; CREATE TABLE ll_fleet_trip_dispatch ( - dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', - dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号', - customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属', - trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用', - trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号', + dispatch_id BIGINT NOT NULL COMMENT '整车排车单内部引用,一车一个有效资源关联', + dispatch_no VARCHAR(64) NOT NULL COMMENT '整车排车业务单号', + trip_id BIGINT NOT NULL COMMENT '本趟运输资源记录内部引用', + trip_no VARCHAR(32) NOT NULL COMMENT '本趟运输资源业务编号', package_count INT NOT NULL COMMENT '关联时待发运箱数快照', bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人', bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间', - review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核', PRIMARY KEY (dispatch_id), UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no), - KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id), + UNIQUE KEY uk_fleet_trip_dispatch_trip (trip_id), CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='整车排车与本趟资源记录一对一关联,历史独立保留'; CREATE TABLE ll_fleet_trip_operations_record ( id BIGINT NOT NULL COMMENT '出车实测历史主键', @@ -4713,4 +4882,4 @@ CREATE TABLE ll_fleet_pickup_history ( KEY idx_pickup_history_trip(trip_no,id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 220 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 52f3dc3ba..850060c90 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -9,6 +9,8 @@ import java.util.Arrays; enum BusinessFileCategory { FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -67,7 +69,12 @@ enum BusinessFileCategory { .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型")); } + /** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */ void validateOwner(long tenantId) { + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) + && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); + } if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台"); } diff --git a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml new file mode 100644 index 000000000..e384ffb41 --- /dev/null +++ b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml @@ -0,0 +1,167 @@ +openapi: 3.0.3 +info: + title: 平台排车车辆与司机证据内部文件契约 + version: 1.0.0 + description: >- + 复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。 + 两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, + 不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用 + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。 + File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、 + PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId + 联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。 + 上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。 + Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。 + 本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。 + 统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。 +security: + - InternalServiceToken: [] +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + post: + operationId: uploadDispatchEvidence + summary: 上传本排车的车辆或司机证据 + parameters: + - name: X-Operator-Id + in: header + required: true + description: Lastleg 已认证操作者的正数 Long ID + schema: {$ref: '#/components/schemas/PositiveLongString'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名 + responses: + '200': {$ref: '#/components/responses/MetadataOrError'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + operationId: readDispatchEvidenceMetadata + summary: 按本排车与证据类别联合校验并读取元数据 + responses: + '200': {$ref: '#/components/responses/MetadataOrError'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + operationId: downloadDispatchEvidence + summary: 按完整复合归属读取文件内容 + responses: + '200': + description: >- + 成功返回真实文件;错误返回 application/json 统一业务错误。 + A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效, + A0404 表示文件不存在、非有效文件或复合归属不匹配。 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名} + Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数} + content: + application/pdf: {schema: {type: string, format: binary}} + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}} +components: + securitySchemes: + InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + TenantId: + name: tenantId + in: path + required: true + description: 两类排车证据只属于平台,客户作用域一律拒绝 + schema: {type: string, enum: ['0']} + BusinessType: + name: businessType + in: path + required: true + schema: {$ref: '#/components/schemas/DispatchEvidenceType'} + BusinessId: + name: businessId + in: path + required: true + description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入 + schema: {$ref: '#/components/schemas/PositiveLongString'} + FileId: + name: fileId + in: path + required: true + schema: {$ref: '#/components/schemas/PositiveLongString'} + responses: + MetadataOrError: + description: >- + 统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件; + A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配; + S0503为存储不可用,S0500为元数据保存失败等内部异常。 + content: + application/json: + schema: + oneOf: + - {$ref: '#/components/schemas/MetadataEnvelope'} + - {$ref: '#/components/schemas/ErrorEnvelope'} + schemas: + PositiveLongString: + type: string + pattern: '^[1-9][0-9]*$' + maxLength: 19 + description: 正数Long的十进制字符串,值不得超过9223372036854775807 + DispatchEvidenceType: + type: string + enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE] + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + message: {type: string} + data: {$ref: '#/components/schemas/Metadata'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt] + properties: + fileId: {$ref: '#/components/schemas/PositiveLongString'} + ownerScope: {type: string, enum: [PLATFORM]} + tenantId: {type: string, enum: ['0']} + businessType: {$ref: '#/components/schemas/DispatchEvidenceType'} + businessId: {$ref: '#/components/schemas/PositiveLongString'} + fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]} + sizeBytes: + type: string + pattern: '^[1-9][0-9]*$' + description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串 + example: '1024' + createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移} + ErrorEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]} + message: {type: string} + data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} diff --git a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java new file mode 100644 index 000000000..2444e080c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java @@ -0,0 +1,245 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.controller.InternalBusinessFileController; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DispatchEvidenceFileTest { + private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE"; + private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE"; + private static final int MAX_BYTES = 10 * 1024 * 1024; + @TempDir Path root; + + /** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */ + @BeforeAll static void metadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"), + FileObjectEntity.class); + } + + /** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var upload = pdf(); + for (long customer : new long[]{-1, 1, 900}) { + assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class); + } + for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) { + assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class); + verifyNoInteractions(files, storage); + } + + /** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception { + var storage = storage(); + var category = BusinessFileCategory.require(type); + for (var file : new MockMultipartFile[]{pdf(), + new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")), + new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) { + var saved = storage.store(0, "71", file, category); + assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes()); + assertThat(saved.contentType()).isEqualTo(file.getContentType()); + assertThat(saved.size()).isEqualTo(file.getSize()); + assertThat(saved.sha256()).matches("[0-9a-f]{64}"); + } + for (var file : new MockMultipartFile[]{ + new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]), + new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)), + new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")), + new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")), + new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()), + new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()), + new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) { + assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class); + } + + /** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception { + var storage = storage(); + var category = BusinessFileCategory.require(type); + byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES); + var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content); + var stored = storage.store(0, "71", boundary, category); + assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES); + var oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); + when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1); + assertThatThrownBy(() -> storage.store(0, "71", oversized, category)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + verify(oversized, never()).getInputStream(); + verify(oversized, never()).transferTo(any(Path.class)); + } + + /** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = storage(); + var service = service(files, storage); + var ids = new AtomicLong(500); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> { + FileObjectEntity row = call.getArgument(0); + row.setId(ids.incrementAndGet()); + return 1; + }); + var first = service.upload(0, type, "71", 7, pdf()); + var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf", + "%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII)); + var second = service.upload(0, type, "71", 7, secondFile); + var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class); + verify(files, times(2)).insert(rows.capture()); + assertThat(first.ownerScope()).isEqualTo("PLATFORM"); + assertThat(first.tenantId()).isZero(); + assertThat(first.businessType()).isEqualTo(type); + assertThat(first.businessId()).isEqualTo("71"); + assertThat(first.fileId()).isEqualTo(501L); + assertThat(second.fileId()).isEqualTo(502L); + assertThat(first.fileName()).isEqualTo("evidence.pdf"); + assertThat(first.contentType()).isEqualTo("application/pdf"); + assertThat(first.sizeBytes()).isEqualTo(pdf().getSize()); + assertThat(first.sha256()).matches("[0-9a-f]{64}"); + assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt()); + assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey()); + assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes()); + assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes()); + verify(files, never()).updateById(any(FileObjectEntity.class)); + } + + /** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var row = new FileObjectEntity(); + row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71"); + row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf"); + row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize()); + row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now()); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(boundValue(query, "status")).isEqualTo("ACTIVE"); + assertThat(boundValue(query, "deleted")).isEqualTo(false); + return Long.valueOf(0).equals(boundValue(query, "tenant_id")) + && type.equals(boundValue(query, "business_type")) + && "71".equals(boundValue(query, "business_id")) + && Long.valueOf(501).equals(boundValue(query, "id")) ? row : null; + }); + when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes()); + var metadata = service.metadata(0, type, "71", 501); + assertThat(metadata.fileId()).isEqualTo(501L); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); + assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes()); + verify(storage).read(row.getObjectKey()); + clearInvocations(storage); + String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE; + for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) { + assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.download(0, candidate, "71", 501)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class); + verifyNoInteractions(storage); + } + + /** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) { + var service = mock(InternalBusinessFileService.class); + var controller = new InternalBusinessFileController(); + ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token"); + ReflectionTestUtils.setField(controller, "service", service); + for (String token : new String[]{null, "", "wrong-token"}) { + assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class); + } + verifyNoInteractions(service); + } + + /** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */ + private static Object boundValue(LambdaQueryWrapper query, String column) { + var matcher = Pattern.compile("(? 'platform:overseas-warehouse:dispatch:list') + OR (id = 5107065 AND permission_code <> 'platform:overseas-warehouse:dispatch:create') + OR (id = 5107066 AND permission_code <> 'platform:overseas-warehouse:dispatch:loading-complete') + OR (id = 5107067 AND permission_code <> 'platform:overseas-warehouse:dispatch:depart') OR (id = 5107016 AND permission_code <> 'platform:overseas-warehouse:dispatch:edit') OR (id = 5107017 AND permission_code <> 'platform:overseas-warehouse:dispatch:cancel') OR (id = 5107018 AND permission_code <> 'platform:overseas-warehouse:dispatch:transfer-delivery') @@ -25,10 +28,12 @@ BEGIN OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit') OR (id = 5107082 AND permission_code <> 'platform:overseas-warehouse:delivery:status') OR (id = 5306000 AND permission_code <> 'merchant:warehouse:delivery:list') - OR (id = 5306001 AND permission_code <> 'merchant:warehouse:delivery:create') ) OR EXISTS ( SELECT 1 FROM iam_permission WHERE (permission_code = 'platform:overseas-warehouse:dispatch:list' AND id <> 5107064) + OR (permission_code = 'platform:overseas-warehouse:dispatch:create' AND id <> 5107065) + OR (permission_code = 'platform:overseas-warehouse:dispatch:loading-complete' AND id <> 5107066) + OR (permission_code = 'platform:overseas-warehouse:dispatch:depart' AND id <> 5107067) OR (permission_code = 'platform:overseas-warehouse:dispatch:edit' AND id <> 5107016) OR (permission_code = 'platform:overseas-warehouse:dispatch:cancel' AND id <> 5107017) OR (permission_code = 'platform:overseas-warehouse:dispatch:transfer-delivery' AND id <> 5107018) @@ -45,7 +50,6 @@ BEGIN OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081) OR (permission_code = 'platform:overseas-warehouse:delivery:status' AND id <> 5107082) OR (permission_code = 'merchant:warehouse:delivery:list' AND id <> 5306000) - OR (permission_code = 'merchant:warehouse:delivery:create' AND id <> 5306001) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限ID或编码冲突'; END IF; @@ -59,8 +63,11 @@ INSERT INTO iam_permission permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'ACTIVE',1,0), + (5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create','新增排车单',NULL,'POST',765,'ACTIVE',1,0), + (5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete','确认装车完成',NULL,'POST',766,'ACTIVE',1,0), + (5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart','确认实际发车',NULL,'POST',767,'ACTIVE',1,0), (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,0), - (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'ACTIVE',1,0), + (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,0), (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0), (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0), (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0), @@ -74,8 +81,7 @@ VALUES (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), (5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'ACTIVE',1,0), (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0), - (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0), - (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0) + (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), @@ -92,6 +98,9 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -117,8 +126,7 @@ SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3) WHERE role.tenant_id>0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') AND permission.status='ACTIVE' AND permission.deleted=0; -- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。 @@ -130,6 +138,9 @@ CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission ( ) ENGINE=MEMORY; INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VALUES ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:create'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:loading-complete'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:depart'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:cancel'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:transfer-delivery'), @@ -160,6 +171,9 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_TALLY','OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -186,7 +200,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0; --- 客户默认管理员获得派送列表/详情只读及独立创建;其他客户角色不自动扩权。 +-- 客户默认管理员只获得派送列表和详情读取;整车派送由平台统一安排。 INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) FROM iam_role role @@ -194,8 +208,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') AND permission.status='ACTIVE' AND permission.deleted=0; DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; @@ -209,6 +222,9 @@ BEGIN WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 AND permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -224,8 +240,7 @@ BEGIN 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:edit', - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create')) <> 18 THEN + 'merchant:warehouse:delivery:list')) <> 20 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -234,6 +249,9 @@ BEGIN WHERE role.role_code='PLATFORM_ADMIN' AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -248,7 +266,7 @@ BEGIN 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:customer-visit:edit')) <> 16 THEN + 'platform:overseas-warehouse:customer-visit:edit')) <> 19 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; END IF; IF EXISTS (SELECT 1 FROM iam_role role @@ -260,6 +278,9 @@ BEGIN WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -274,14 +295,14 @@ BEGIN 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:customer-visit:edit')) <> 16) THEN + 'platform:overseas-warehouse:customer-visit:edit')) <> 19) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; END IF; SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count FROM tmp_phase5_delivery_role_permission; - IF v_warehouse_operator_permission_count <> 16 + IF v_warehouse_operator_permission_count <> 19 OR v_warehouse_tally_permission_count <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; END IF; @@ -291,6 +312,9 @@ BEGIN WHERE role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -313,11 +337,10 @@ BEGIN ON grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id LEFT JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' AND role.status='ACTIVE' AND role.deleted=0 - GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 2) THEN + GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 1) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整'; END IF; END$$ diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index cd0957681..d96d8e78d 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -146,7 +146,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:create`、`platform:overseas-warehouse:dispatch:loading-complete`、`platform:overseas-warehouse:dispatch:depart`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 @@ -364,7 +364,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板的列表入口分别返回 `merchant:cross-border:procurement:list`、`merchant:cross-border:domestic-trucking:list`、`merchant:cross-border:pallet-shipping:list`,创建和编辑另行授权;海外仓入库返回 `merchant:warehouse:inbound:list`,尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:overseas-warehouse:*` 权限。客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。 + description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板的列表入口分别返回 `merchant:cross-border:procurement:list`、`merchant:cross-border:domestic-trucking:list`、`merchant:cross-border:pallet-shipping:list`,创建和编辑另行授权;海外仓入库返回 `merchant:warehouse:inbound:list`,尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:overseas-warehouse:*` 权限。客户派送仅返回 `merchant:warehouse:delivery:list`,整车派送由平台统一安排。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。 responses: '200': description: 当前商户成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index d23670498..936df385e 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -10,6 +10,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class IamOpenApiContractTest { + /** 核对当前授权接口公开说明,整车写权限独立且客户派送只读。 */ @Test void shouldHandleCase01() throws IOException { String document; @@ -21,6 +22,9 @@ class IamOpenApiContractTest { assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:") .contains("/api/merchant/v1/iam/authorization/permissions:") .contains("platform:overseas-warehouse:dispatch:list") + .contains("platform:overseas-warehouse:dispatch:create") + .contains("platform:overseas-warehouse:dispatch:loading-complete") + .contains("platform:overseas-warehouse:dispatch:depart") .contains("platform:overseas-warehouse:dispatch:edit") .contains("platform:overseas-warehouse:dispatch:cancel") .contains("platform:overseas-warehouse:dispatch:transfer-delivery") @@ -45,7 +49,7 @@ class IamOpenApiContractTest { .contains("platform:firstleg:terminal-drayage:empty-return:confirm") .contains("不以 `container:list/edit` 兼容放行国外操作") .contains("merchant:warehouse:delivery:list") - .contains("merchant:warehouse:delivery:create") + .doesNotContain("merchant:warehouse:delivery:create") .contains("merchant:cross-border:procurement:list") .contains("merchant:cross-border:domestic-trucking:list") .contains("merchant:cross-border:pallet-shipping:list") diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 4314ad8fe..619cd0d41 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -39,8 +39,7 @@ class OverseasPhase4PermissionSqlContractTest { private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); private static final Set CUSTOM_ADMIN_PERMISSIONS = Set.of( - "platform:overseas-warehouse:inbound-order:fba-appointment", - "platform:overseas-warehouse:inbound-order:smart-dispatch"); + "platform:overseas-warehouse:inbound-order:fba-appointment"); private static final List REQUIRED = List.of( "platform:overseas-warehouse:last-mile-order:list", "platform:overseas-warehouse:discard:list", @@ -48,7 +47,6 @@ class OverseasPhase4PermissionSqlContractTest { "platform:overseas-warehouse:warehouse-transfer:approve", "platform:overseas-warehouse:inbound-order:list", "platform:overseas-warehouse:inbound-order:fba-appointment", - "platform:overseas-warehouse:inbound-order:smart-dispatch", "platform:overseas-warehouse:relabel-order:status", "platform:overseas-warehouse:pallet:list", "platform:overseas-warehouse:inventory:adjust", @@ -77,7 +75,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(71).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(70).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains( "(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()", @@ -85,7 +83,8 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).doesNotContain( "'platform:overseas-warehouse:warehouse-transfer:list','仓储转运审核'"); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); - assertThat(sql).doesNotContain(":carton:", ":product:"); + assertThat(sql).doesNotContain(":carton:", ":product:", + "platform:overseas-warehouse:inbound-order:smart-dispatch"); assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); assertCustomAdminGrant(sql); assertCustomAdminNavigationGrant(sql); diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java index ee5ac806d..9d97b9d25 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -16,6 +16,9 @@ class Phase5DeliveryPermissionSqlContractTest { private static final Pattern CODE = Pattern.compile("(?:platform:overseas-warehouse|merchant:warehouse):[a-z0-9:-]+"); private static final Set REQUIRED = Set.of( "platform:overseas-warehouse:dispatch:list", + "platform:overseas-warehouse:dispatch:create", + "platform:overseas-warehouse:dispatch:loading-complete", + "platform:overseas-warehouse:dispatch:depart", "platform:overseas-warehouse:dispatch:edit", "platform:overseas-warehouse:dispatch:cancel", "platform:overseas-warehouse:dispatch:transfer-delivery", @@ -31,15 +34,16 @@ class Phase5DeliveryPermissionSqlContractTest { "platform:overseas-warehouse:delivery:documents", "platform:overseas-warehouse:customer-visit:list", "platform:overseas-warehouse:customer-visit:edit", - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"); + "merchant:warehouse:delivery:list"); + /** 核对整车权限目录只包含当前正式编码。 */ @Test void definesOnlyTheFrozenPhase5DeliveryCatalogWithoutAliases() throws Exception { String sql = sql(); assertThat(codes(sql)).containsExactlyInAnyOrderElementsOf(REQUIRED); assertThat(codes(sql)).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).doesNotContain("merchant:warehouse:delivery:create"); assertThat(sql).contains( "guard_phase5_delivery_permission_ids", "权限ID或编码冲突", @@ -47,27 +51,32 @@ class Phase5DeliveryPermissionSqlContractTest { "列表权限同时覆盖列表和详情只读"); } + /** 核对菜单、动作方法及独立装车发车权限。 */ @Test void keepsMenuRoutesAndHttpMethodsAlignedWithTheFrozenAdminAndClientContracts() throws Exception { String sql = sql(); assertThat(sql).contains( "'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET'", - "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", + "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST'", "'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET'", "'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'", "'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'", + "(5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create'", + "(5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete'", + "(5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart'", "(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST'", "'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'", "'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT'", "'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET'"); assertThat(sql).doesNotContain( - "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST'", + "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", "'merchant:warehouse:delivery:list','海外仓派送单','/overseas-warehouse/delivery-orders'"); } + /** 核对后台操作岗位、理货只读岗位与PDA之间的最小授权边界。 */ @Test void grantsAdministratorsAndWarehouseRolesByTheFrozenMinimumMatrix() throws Exception { String sql = sql(); @@ -76,6 +85,9 @@ class Phase5DeliveryPermissionSqlContractTest { "UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')", "tmp_phase5_delivery_role_permission", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:list')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:create')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:loading-complete')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:depart')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create')", @@ -92,6 +104,9 @@ class Phase5DeliveryPermissionSqlContractTest { "role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')", "PDA岗位错误获得后台权限"); assertThat(sql).doesNotContain( + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:create')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:loading-complete')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:depart')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:create')", @@ -104,6 +119,7 @@ class Phase5DeliveryPermissionSqlContractTest { "('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:"); } + /** 核对临时角色矩阵一次读取及完整性判断。 */ @Test void validatesTheWarehouseRoleMatrixWithOneTemporaryTableReadPerStatement() throws Exception { String sql = sql(); @@ -114,7 +130,7 @@ class Phase5DeliveryPermissionSqlContractTest { "SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)", "COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)", "INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count", - "IF v_warehouse_operator_permission_count <> 16", + "IF v_warehouse_operator_permission_count <> 19", "OR v_warehouse_tally_permission_count <> 5 THEN", "IF EXISTS (SELECT 1 FROM iam_role role", "AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", @@ -126,10 +142,11 @@ class Phase5DeliveryPermissionSqlContractTest { "OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission"); } + /** 客户默认管理员只获得本客户派送读取权限。 */ @Test void grantsOnlyTheDefaultCustomerAdministratorAutomatically() throws Exception { String sql = sql(); - int marker = sql.indexOf("-- 客户默认管理员获得派送列表/详情只读及独立创建"); + int marker = sql.indexOf("-- 客户默认管理员只获得派送列表和详情读取"); int assertion = sql.indexOf("DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions", marker); assertThat(marker).isGreaterThanOrEqualTo(0); assertThat(assertion).isGreaterThan(marker); @@ -137,11 +154,11 @@ class Phase5DeliveryPermissionSqlContractTest { String grant = sql.substring(marker, assertion); assertThat(grant).contains( "role.role_code='MERCHANT_OWNER'", - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"); - assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN"); + "merchant:warehouse:delivery:list"); + assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN", "merchant:warehouse:delivery:create"); } + /** 国内空库基线必须同步当前权限与默认角色关联。 */ @Test void domesticCleanInitializationContainsTheCompletePermissionCatalog() throws Exception { String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); @@ -151,7 +168,10 @@ class Phase5DeliveryPermissionSqlContractTest { .contains("(4000004,5107064,0,CURRENT_TIMESTAMP(3))") .contains("(4000004,5107094,0,CURRENT_TIMESTAMP(3))") .contains("(4000100,5306000,0,CURRENT_TIMESTAMP(3))") - .contains("(4000100,5306001,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107065,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107066,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107067,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("merchant:warehouse:delivery:create", "(4000100,5306001,0,CURRENT_TIMESTAMP(3))") .doesNotContain("guard_phase5_delivery_permission_ids", "tmp_phase5_delivery_role_permission"); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java index 3d58e8216..cd2209cc3 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java @@ -20,6 +20,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { private static final Pattern PLATFORM_CODE = Pattern.compile("platform:overseas-warehouse:[a-z0-9:-]+"); + /** 没有自定义管理员时不产生虚假的缺权错误。 */ @Test void noCustomAdministratorDoesNotCreateAnAssertionViolation() throws Exception { try (Connection connection = database()) { @@ -29,6 +30,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { } } + /** 按当前完整目录补齐管理员授权,重复执行不增加关联。 */ @Test void onePartiallyGrantedCustomAdministratorIsCompletedIdempotently() throws Exception { try (Connection connection = database()) { @@ -37,50 +39,53 @@ class Phase5DeliveryPermissionSqlExecutionTest { assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(16); + assertThat(grantCount(connection, 11L)).isEqualTo(19); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(16); - assertThat(totalGrantCount(connection)).isEqualTo(16); + assertThat(grantCount(connection, 11L)).isEqualTo(19); + assertThat(totalGrantCount(connection)).isEqualTo(19); } } + /** 分别核对每个管理员,缺少任一权限不能被其他角色抵消。 */ @Test void multipleCustomAdministratorsExposeTheSingleIncompleteRole() throws Exception { try (Connection connection = database()) { insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 22L, "Platform_Custom_Admin"); - grantFirstPermissions(connection, 21L, 16); - grantFirstPermissions(connection, 22L, 15); + grantFirstPermissions(connection, 21L, 19); + grantFirstPermissions(connection, 22L, 18); assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 21L)).isEqualTo(16); - assertThat(grantCount(connection, 22L)).isEqualTo(16); + assertThat(grantCount(connection, 21L)).isEqualTo(19); + assertThat(grantCount(connection, 22L)).isEqualTo(19); assertThat(customAdministratorViolations(connection)).isZero(); } } + /** 完整管理员重复授权保持各自权限集合稳定。 */ @Test void multipleCompleteCustomAdministratorsRemainCompleteAfterReplay() throws Exception { try (Connection connection = database()) { insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 32L, "platform_custom_admin"); - grantFirstPermissions(connection, 31L, 16); - grantFirstPermissions(connection, 32L, 16); + grantFirstPermissions(connection, 31L, 19); + grantFirstPermissions(connection, 32L, 19); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); executeGrant(connection); - assertThat(grantCount(connection, 31L)).isEqualTo(16); - assertThat(grantCount(connection, 32L)).isEqualTo(16); - assertThat(totalGrantCount(connection)).isEqualTo(32); + assertThat(grantCount(connection, 31L)).isEqualTo(19); + assertThat(grantCount(connection, 32L)).isEqualTo(19); + assertThat(totalGrantCount(connection)).isEqualTo(38); } } + /** 管理员授权不得扩散到理货只读岗位或停用账号。 */ @Test void administratorGrantDoesNotEscalateAReadOnlyRoleOrDisabledAdministrator() throws Exception { try (Connection connection = database()) { @@ -99,7 +104,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { executeGrant(connection); executeGrant(connection); - assertThat(grantCount(connection, 41L)).isEqualTo(16); + assertThat(grantCount(connection, 41L)).isEqualTo(19); assertThat(grantCount(connection, 42L)).isEqualTo(1); assertThat(grantCount(connection, 43L)).isZero(); try (Statement statement = connection.createStatement(); @@ -206,6 +211,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { } } + /** 逐角色核对当前后台权限全集,避免跨角色合并计数。 */ private static int customAdministratorViolations(Connection connection) throws Exception { String placeholders = String.join(",", platformPermissions().stream().map(code -> "?").toList()); String sql = """ @@ -216,7 +222,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { AND (SELECT COUNT(*) FROM iam_role_permission grant_row JOIN iam_permission permission ON permission.id=grant_row.permission_id WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id - AND permission.permission_code IN (%s)) <> 16 + AND permission.permission_code IN (%s)) <> 19 """.formatted(placeholders); try (PreparedStatement query = connection.prepareStatement(sql)) { int index = 1; @@ -239,11 +245,12 @@ class Phase5DeliveryPermissionSqlExecutionTest { return sql.substring(start, end); } + /** 从实际管理员授予语句提取当前后台权限全集。 */ private static Set platformPermissions() throws Exception { LinkedHashSet codes = new LinkedHashSet<>(); String grant = administratorGrantSql(); PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add); - assertThat(codes).hasSize(16); + assertThat(codes).hasSize(19); return codes; } diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index f8d83d00f..f5359e6c1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -64,18 +64,16 @@ class AuthorizationSnapshotServiceTest { verify(mapper).findSnapshot(42L, 0L, 9L, "PLATFORM"); } + /** 按当前有效角色返回客户派送读取权限。 */ @Test - void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() { + void exposesCustomerDeliveryReadPermissionFromCurrentRoleAssignments() { when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"))); + "merchant:warehouse:delivery:list"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); - assertThat(result).containsExactlyInAnyOrder( - "merchant:warehouse:delivery:create", - "merchant:warehouse:delivery:list"); + assertThat(result).containsExactly("merchant:warehouse:delivery:list"); } @Test diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 6e6fbc6e6..294daed50 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -16,7 +16,7 @@ 业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。 -拼车接货与车次准备:`fleet/trip/`、`fleet/business/`;合同`fleet-preparation-v1.yaml`,流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 +拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 @@ -55,8 +55,8 @@ | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | -| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java` | -| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java` | +| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | +| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` | @@ -73,10 +73,10 @@ | 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` | | 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 | | 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 | -| 调度与理货审核 | `FleetTripService.reviewDispatch`、`FleetTripExecutionService.reviewLoading`;每张客户排车单先调度通过,整车确认实载后理货通过。`FleetDispatchBindingGuard.requirePickingApproved` 在新拣货/完成前核对当前单审核;已成功操作的同键重放保留原结果 | +| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | -| 实物装载、逐段容量与整车PDA发车 | `fleet/trip/FleetTripExecutionService.java`、`FleetExecutionModels.java`;平台 `/fleet/trips/{tripNo}/loading`,PDA沿用 `/dispatches/{dispatchNo}/depart` 但提交车次版本并返回整车结果;`DispatchPdaWorkflowService.depart` 为车次事务内子操作,不独立对外调用 | +| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 | | 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | @@ -95,12 +95,12 @@ - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 -- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 -- 共享车次关联取排车单自身客户与起运仓;排车有效关联唯一,多客户不合并库存归属。`FleetDispatchBindingGuard` 在子单行锁内阻止独立编辑/移托/取消及绕过车次协调单独发车;转派送的新建请求不能覆盖车次车辆司机,已成功转换的重放继续返回既有单据。PDA详情同步返回车次统一发车阻断原因;关联与改派通过 `FleetDispatchGateway` 同事务同步尚未执行的派送快照。已拣货禁止直接解除关联或变更路线;发车前可统一换车/司机或保持路线调整时间,保留拣货事实与到达时间,重验司机任务覆盖及替换车辆对已确认装载的容量,成功后调度审核、理货审核及原装载确认失效;仅修改备注保留审核与装载。已出库/发车不允许此类修改。 -- 实物装载按仓库托盘ID去重计量,客户货量与物理占位分别保存;重量/体积为明确确认的实际值,不读取计费材积重回填。路线卸货站的 `destinationKey` 必须匹配原货物目的地,同实托不同目的地先拆托。装载确认保存来源hash、版本、操作人和历史;改计划/改派/关联变化使确认失效。整车发车在同一Lastleg事务中复核所有子单、车辆占用及司机证照,调用原子单出库命令后统一提交 `IN_TRANSIT` 和幂等结果;任一失败全车回滚,`DEPARTING`只在事务内部使用。PDA列表按车次复用发车门禁查询,再按当前客户排车单审核返回canPick/pickBlockReason;客户A通过不能代替客户B通过。重新确认装载或重新调度审核使理货审核待审,审核历史不删除;过期前30天提醒与阻断分别返回。 +- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 +- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 +- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 - 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 - 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 -- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 +- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 - 外接预订以父车次行锁串行确认容量;DRAFT不占位,CONFIRMED占位,CANCELLED释放,创建/确认/取消保留请求键与内容摘要。报价与记录快照进入既有车次历史。重新核对自有货计划不得占用已确认外接容量,换小车须能容纳两者;同车只换司机不额外要求先重新核对容量。CONFIRMED和RECEIVED尚未绑定实际发运时阻止整车发车;通过真实来货实测、运费确认及来源核对后转为BOUND,解除未开工排车关联回RECEIVED。报价或登记来货不冒充库存/已出库;取消未执行预订仅释放承运容量,来货及已入库货物保留。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 0ec4d6a1e..37aa6e714 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -80,3 +80,82 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | + +## 海外仓全流程开发路由 + +- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 +- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 +- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 +- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 + +- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。 + +- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 +- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 +- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 +- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 +- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 +- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 +- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 +- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 +- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 + +- 外接预订以父车次行锁串行确认容量;DRAFT不占位,CONFIRMED占位,CANCELLED释放,创建/确认/取消保留请求键与内容摘要。报价与记录快照进入既有车次历史。重新核对自有货计划不得占用已确认外接容量,换小车须能容纳两者;同车只换司机不额外要求先重新核对容量。CONFIRMED和RECEIVED尚未绑定实际发运时阻止整车发车;通过真实来货实测、运费确认及来源核对后转为BOUND,解除未开工排车关联回RECEIVED。报价或登记来货不冒充库存/已出库;取消未执行预订仅释放承运容量,来货及已入库货物保留。 +- 外接约定运费由`fleet/trip/FleetBookingFreight`按排车来源、绑定车次、目的地和完整实收量复核;`DeliveryTransferService`在原转派送事务内发布到对应目的地派送单的`OVERSEAS_DELIVERY_FEE`,通过既有费用Outbox进入Finance。预览只读且使用dispatch:list;确认原货物/报价摘要后写入,全部外接货禁止再填派送应收,额外装车/买约仍独立。明确零约定留操作事实不发布虚构零费用。首版确认承运币种限现行费用支持的CNY/USD,原头程费用覆盖按下条处理。 +- 原运费覆盖由`delivery/service/DeliveryFeeCoverageService`与`DispatchFeeCargoMapper`按实际库存组成及本次已拣数量还原,核平每条排车箱数;不可用MIN展示入仓码替代整托全部来源。`FinanceFeeCoverageClient`批量只读Finance,平台转派送按费用ID/版本/hash和当前货物/目的地摘要复核,只有全部相关货物已覆盖的服务才免新增应收,供应商应付独立。客户接口禁止自行声明覆盖。确认快照保存到`ll_outbound_order.fee_coverage_json`,原费用不复制、不抵扣金额、不生成伪零费用;详情按本派送货物过滤只读快照,之后财务更正不自动改写旧发运。普通无引用请求保持原新增收费要求。 +- 派送出库取消按dispatch→outbound加锁,核对排车未拣货/发车并先解除车次关联;取消出库不代替取消排车占用。来源订单按本次转派送历史的原状态恢复,显式清空出库/派送关联并增加版本,不统一写RECEIVED。只对本派送当前已审核有效的DELIVERY_FEE应收申请作废,其他费用保留核对,完整取消原因分段保存在派送操作记录。 +- `financefee/state/OverseasFeeCancellation`从不可变费用Outbox批量读取voidStatus;请求/拒绝/未知结果保留ACTIVE,只有Finance匹配APPLIED/DUPLICATE后,在投递审计同事务中落VOIDED。撤销候选必须等待原发布成功,未满足前置的PENDING撤销在LIMIT前排除,避免堵住后续投递;过期PROCESSING仍按租约恢复。费用调整使用READ_COMMITTED并在来源行锁后检查未决撤销,禁止旧快照漏检。前端与API区分待财务确认、待财务处理和确认作废,不能以物流取消或HTTP请求发出认定账务已撤销。 + +## 事件建单规则 + +- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 +- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 +- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 +- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 +- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 +- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 +- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- 通用 `/internal/lastleg/events` 只白名单接收装柜完成、实际船节点与资金结果事件;未知或IAM安全事件必须拒绝。 +- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 + +## 查询与租户约束 + +- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 +- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 +- 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。普通尾程入库只确认某入仓单在托盘上的箱数,没有逐箱唛到托盘的快照;贴标下单由客户填写我方箱唛,库存仓位只作为冻结记账锚点和现场大概位置,不能据此声称箱唛位于某个具体托盘。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。 +- 换标箱唛经现场确认后执行 `pallet-operations/relabel-disassemble`:允许仅移动该换标单对应的 `hold_packages`,不得解冻为普通可用库存;事务内同步更新 `ll_relabel_item.source_inventory_position_id`。若客户确认后已生成换标出库单,还必须在拣货开始前同步拆分或改写 `ll_outbound_pallet_item` 的托盘、箱唛、箱数与来源仓位快照,使 PDA 后续按新旧托盘分别扫描且每个指定箱唛只出库一次。 +- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 +- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 +- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 +- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 +- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 +- 派车状态只允许 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`。 + +## 修改与验证 + +- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。 + +- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。 + +- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。 + +- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。 + +- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。 + +- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。 + +- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 + +- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 + +- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 +- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。 +- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 +- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 68b4e6ef4..dc68a0cbe 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -20,6 +20,10 @@ - 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。 - 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。 +### 整车排车的既有预约接线边界(2026-10-19) + +既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。 + ### 平台自营统计边界 `selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 95eb6013e..b8ccfdaa2 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -34,7 +34,7 @@ default-test - **/*SqlSemanticTest.java + false**/*SqlSemanticTest.java diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index 404ea68be..44371e5bc 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -73,6 +73,40 @@ function analyze(sourceSet = 'main') { })); } +test('original appointment adapters use only the reviewed dispatch port types', () => { + const report = analyze(); + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const prefix = 'com.baiyue.lastleg.inboundplan.work.'; + const port = 'com.baiyue.lastleg.dispatch.port.'; + const expected = [ + ['FbaAppointmentExecutionAdapter', 'DispatchAppointmentAmendmentPort'], + ['FbaAppointmentExecutionAdapter', 'DispatchContracts'], + ['FbaAppointmentExecutionAdapter', 'DispatchTypes'], + ['FbaAppointmentTransportAdapter', 'DispatchAppointmentAmendmentPort'], + ['FbaAppointmentTransportAdapter', 'DispatchCommands'], + ['FbaAppointmentTransportAdapter', 'DispatchContracts'], + ['FbaAppointmentTransportAdapter', 'DispatchTypes'], + ].map(([source, target]) => `${prefix}${source}->${port}${target}`).sort(); + const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.dispatch.')); + assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), expected); + assert.equal(baseline.allowedSliceEdges.includes('inbound-pda->dispatch-delivery'), false); + const violations = contractViolations(report, baseline); + for (const edge of expected) assert.equal(violations.some(message => message.includes(edge)), false); + + // 精确端口边不允许原适配器直连整车实现,也不授权其他预约消费者借用此许可。 + const forbidden = [ + { source: prefix + 'FbaAppointmentExecutionAdapter', target: 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' }, + { source: prefix + 'AppointmentWorkDispatchBinding', target: port + 'DispatchAppointmentAmendmentPort' }, + ]; + for (const edge of forbidden) { + assert.ok(report.classes.some(entry => entry.fqcn === edge.source)); + assert.ok(report.classes.some(entry => entry.fqcn === edge.target)); + report.classDependencies.push(edge); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`))); + } +}); + test('current source does not add slice dependencies beyond the Lastleg baseline', () => { execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { cwd: moduleRoot, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java index 1072fb67c..95068c989 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java @@ -19,11 +19,11 @@ public interface AppointmentImpactRelationMapper { """) List appointments(String id); @Select(""" - SELECT DISTINCT d.customer_id,d.dispatch_no FROM ll_order o + SELECT DISTINCT i.customer_id,d.dispatch_no FROM ll_order o JOIN ll_dispatch_item i ON i.order_id=o.id AND i.customer_id=o.customer_id - JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.customer_id=i.customer_id + JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE o.source_container_booking_id=#{id} AND o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED') - AND d.status NOT IN ('CANCELLED','COMPLETED') ORDER BY d.dispatch_no LIMIT 1001 + AND d.status='ACTIVE' AND d.stage<>'DELIVERED' ORDER BY d.dispatch_no LIMIT 1001 """) List dispatches(String id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 0428cd0ff..80182e0a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -2,18 +2,26 @@ package com.baiyue.lastleg.appointmentwork; import jakarta.validation.constraints.*; import java.util.List; +import java.util.Map; /** Real dispatch allocation boundary, implemented by the original appointment domain. */ public interface AppointmentWorkDispatchPort { - record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId, + record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotNull @PositiveOrZero Long recordVersion,@NotBlank String customerId, @NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { } - record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { } + record PhysicalItem(Long itemId,String customerId,Long planPalletId,String sourceStage,Integer packages) { } record BatchSource(String sourceKey,String destinationKey,int packageCount) { } - record BatchReference(String batchKey,String batchNo,String customerId,List sources) { } + record BatchReference(String batchKey,String batchNo,Long recordVersion,String customerId,List sources) { } + record CandidatePhysical(String customerId,Long planPalletId) { } + /** sources数量由本托真实逐票量和原批次剩余量共同限定;不包含零余量的伪可选项。 */ + record CandidateBatches(List appointmentBatches,List blockReasons,boolean occupied) { } + /** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */ + Map candidateBatches(List candidates); /** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */ java.util.Map> references(List dispatchIds); - void bind(String customerId,long dispatchId,List items,List allocations); - void requireReplay(String customerId,long dispatchId,List allocations); + /** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */ + void lockBatches(Map expectedBatchVersions); + /** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */ + void bindItems(long dispatchId,List touchedItems,List allocations); void requireDeparture(String dispatchNo); /** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */ String departureBlockReason(String dispatchNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index b2410ec24..7658c2a67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -6,6 +6,10 @@ 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 com.baiyue.lastleg.delivery.service.DispatchDeliveryService; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,23 +26,34 @@ import org.springframework.web.server.ResponseStatusException; @Tag(name = "Outbound and delivery") public class DeliveryTransferController { @Resource private DeliveryTransferService service; + @Resource private DispatchDeliveryService dispatchDelivery; + /** 只读辅助入口复用同一整车组合预览,不保留单客户报价协议。 */ @GetMapping("/dispatches/{dispatchNo}/external-freight") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") - public ApiResponse externalFreight( + public ApiResponse> externalFreight( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { requirePlatform(user); - return ApiResponse.success(service.externalFreight(dispatchNo)); + return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups()); } + /** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */ + @GetMapping("/dispatches/{dispatchNo}/delivery-arrangement") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") + public ApiResponse arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + requirePlatform(user); + return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo)); + } + + /** 平台一次安排本车全部客户,身份只从认证上下文获取。 */ @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") - @Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse") - public ApiResponse transfer( + @Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单") + public ApiResponse transfer( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody DeliveryRequests.Transfer request) { + @Valid @RequestBody ArrangeDelivery request) { requirePlatform(user); - return ApiResponse.success(service.platformTransfer(dispatchNo, operator(user), request)); + return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request)); } @PostMapping("/outbound-orders/one-click-dropship") @@ -160,6 +175,8 @@ public class DeliveryTransferController { private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + /** 平台安排不能使用请求体提供的客户或操作者身份。 */ + private Actor actor(AuthenticatedUser user) { return new Actor(operator(user),ActorMode.PLATFORM,null); } private String listScope(AuthenticatedUser user, String requestedCustomer) { requirePlatform(user); return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java index 9c0827c27..ca3a5ddb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -31,14 +31,6 @@ public class MerchantDeliveryController { return ApiResponse.success(service.deliveryPage(customer(user), query)); } - @PostMapping - @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')") - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam String dispatchNo, - @Valid @RequestBody DeliveryRequests.Transfer request) { - return ApiResponse.success(service.merchantTransfer(customer(user), dispatchNo, operator(user), request)); - } - @GetMapping("/{deliveryNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @@ -71,5 +63,4 @@ public class MerchantDeliveryController { if (!"MERCHANT".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/delivery/controller/PlatformDeliveryFeeCoverageController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java index b18bd481e..8878c851b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java @@ -2,8 +2,10 @@ package com.baiyue.lastleg.delivery.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; -import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService; +import com.baiyue.lastleg.delivery.service.DispatchDeliveryService; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.PreviewGroup; import com.baiyue.lastleg.security.OverseasPermissions; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -16,13 +18,15 @@ import org.springframework.web.server.ResponseStatusException; @RequiredArgsConstructor @RequestMapping("/api/platform/v1/last-leg/dispatches") public class PlatformDeliveryFeeCoverageController { - private final DeliveryFeeCoverageService service; + private final DispatchDeliveryService service; + /** 原费辅助查询复用真实整车分组,不能再从头部推定单客户。 */ @GetMapping("/{dispatchNo}/fee-coverage") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") - public ApiResponse preview(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse> preview(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN); - return ApiResponse.success(service.preview(dispatchNo)); + String operator=user.userId()==null?user.username():user.userId().toString(); + return ApiResponse.success(service.preview(new Actor(operator,ActorMode.PLATFORM,null),dispatchNo).groups()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java index 805b90c08..64e3c9e8d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java @@ -9,10 +9,6 @@ public final class DeliveryFeeCoverageModels { private DeliveryFeeCoverageModels() { } public record Cargo(Long dispatchItemId, Long warehousePalletId, String palletNo, Long orderId, String sourceRegion, String sourceInboundId, String inboundCode, long packages, String dispatchInboundCode) { } - public record Preview(String dispatchNo, boolean sourceComplete, String blockReason, String cargoHash, - List cargo, List feeSources) { } - public record Selection(@NotNull @Positive Long financeFeeId, @Min(1) int sourceVersion, - @NotBlank @Size(max=128) String sourceHash, @NotEmpty @Size(max=3) List<@NotBlank @Pattern(regexp="DELIVERY_FEE|LOADING_FEE|APPOINTMENT_FEE") String> services) { } public record Reference(FinanceFeeCoverageClient.Fee source, List services) { } public record Evidence(String cargoHash, List cargo, List references, String operatorId, Instant confirmedAt) { } } 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 index e4943f8a5..029acd54c 100644 --- 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 @@ -6,7 +6,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import jakarta.validation.Valid; -import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection; import java.util.List; import java.util.Collections; import java.math.BigDecimal; @@ -16,46 +15,6 @@ import java.time.LocalDate; public final class DeliveryRequests { private DeliveryRequests() {} - public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, - @NotNull LocalDateTime plannedDeliveryAt, - @NotBlank @Size(max = 32) String deliveryMethod, - @Size(max = 128) String waybillNo, - @Size(max = 255) String carrierName, - @NotBlank @Size(max = 64) String pickupMethod, - @NotBlank @Size(max = 64) String loadingType, - @Size(max = 64) String vehicleType, - @jakarta.validation.constraints.Pattern(regexp = "15托|30托") String vehicleSize, - @Size(max = 128) String driverName, - @Size(max = 64) String driverPhone, - @Size(max = 64) String vehiclePlate, - @Size(max = 128) String appointmentAccount, - @Size(max = 128) String sellerName, - @Size(max = 1000) String remark, - @NotNull @Valid @Size(max = 20) List fees, - @Size(max = 64) String externalFreightHash, - @Size(max = 64) String feeCoverageCargoHash, - @Size(max = 2000) List<@NotNull @Valid Selection> feeCoverage) {} - - public record FeeItem(@NotBlank @Size(max = 64) String feeType, - @NotBlank @Size(max = 128) String feeName, - @NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, - @NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency, - @NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount, - @Min(1) Long supplierId, - @Size(max = 200) String supplierName, - @Size(max = 32) String supplierCategory, - @jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, - LocalDateTime paidAt, - @jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, - @Size(max = 128) String paymentReferenceNo, - @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds, - @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity, - @Size(max = 32) String quantityUnit, - @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice, - LocalDate periodStart, - LocalDate periodEnd, - @Size(max = 500) String remark) {} - public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String documentNo, String lxCode, String warehouseId, String status, String dispatchNo, String waybillNo, String vehiclePlate, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index a30ed1d3e..15d65e1de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -8,14 +8,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; public final class DeliveryResponses { private DeliveryResponses() {} - public record TransferDelivery(String deliveryNo, String deliveryStatus, - String destinationWarehouseId, String destinationWarehouseCode, - String destinationWarehouseName, int palletCount, int packageCount) {} - - public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, - int palletCount, int packageCount, - List deliveries, boolean replayed) {} - public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes, String status, int palletCount, int packageCount, BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java new file mode 100644 index 000000000..d59c14ccc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.delivery.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 签收事务内保存的本地待汇总事实,不携带整车已完成结论。 */ +@Data +public class DeliveryFactsChanged { + private Long id; + private String customerId; + private Long dispatchId; + private String dispatchNo; + private Long deliveryId; + private String deliveryNo; + private Long deliveryVersion; + private String factsHash; + private LocalDateTime occurredAt; + private String state; + private Integer attemptCount; + private LocalDateTime nextAttemptAt; + private String lastErrorCode; + private LocalDateTime processedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java new file mode 100644 index 000000000..baeb4f5ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.delivery.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 每趟车唯一安排事实,保留完整客户结果及真实来源。 */ +@Data +public class DispatchDeliveryArrangement { + private Long id; + private Long dispatchOrderId; + private String dispatchNo; + private String arrangementKey; + private String businessHash; + private String cargoHash; + private String loadingCompletionHash; + private String originalCommandKey; + private String originalOperatorId; + private String resultJson; + private String cargoJson; + private LocalDateTime createdAt; +} 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 index 3345ff9d3..d70dc6a4f 100644 --- 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 @@ -16,6 +16,14 @@ public class OutboundPalletItem { private Long id; private String customerId; private Long outboundOrderId; + /** 排车出库明确关联原作业明细,不从托盘展示字段猜来源。 */ + private Long dispatchItemId; + /** 对应本次真实PDA拣货归属,作为客户和数量核对的不可变来源。 */ + private Long sourceAttributionId; + private Long sourceOrderId; + private Long sourceInventoryPositionId; + private Long sourceReturnReceiptId; + private Long sourceReturnCartonId; private Long warehousePalletId; private String palletNo; private String destinationWarehouseId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java new file mode 100644 index 000000000..5f70d7a76 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 本地签收信号的唯一写入、扫描、确认及失败记录。 */ +@Mapper +public interface DeliveryFactsChangedMapper { + /** 同一派送版本只登记一次,不覆盖首次不可变事实。 */ + @Insert(""" + INSERT INTO ll_delivery_facts_changed + (id,customer_id,dispatch_id,dispatch_no,delivery_id,delivery_no,delivery_version,facts_hash, + occurred_at,state,attempt_count,next_attempt_at) + VALUES (#{id},#{customerId},#{dispatchId},#{dispatchNo},#{deliveryId},#{deliveryNo}, + #{deliveryVersion},#{factsHash},#{occurredAt},'PENDING',0,#{occurredAt}) + ON DUPLICATE KEY UPDATE id=id + """) + int insertOnce(DeliveryFactsChanged fact); + + /** 唯一键等待后读取当前已保存事实,不能用旧快照确认异载荷重放。 */ + @Select(""" + SELECT * FROM ll_delivery_facts_changed + WHERE customer_id=#{customerId} AND delivery_id=#{deliveryId} AND delivery_version=#{version} + """) + DeliveryFactsChanged find(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId, + @Param("version") long version); + + /** 按已扫描信号身份读取,不取得父级之前的子行锁。 */ + @Select("SELECT * FROM ll_delivery_facts_changed WHERE id=#{id}") + DeliveryFactsChanged findById(@Param("id") long id); + + /** 普通读候选,不在申请整车父锁之前锁定信号行。 */ + @Select(""" + SELECT * FROM ll_delivery_facts_changed + WHERE state='PENDING' AND next_attempt_at<=#{now} + ORDER BY next_attempt_at,id LIMIT #{limit} + """) + List pending(@Param("now") LocalDateTime now, @Param("limit") int limit); + + /** 父级汇总同事务仅确认指定的已核对信号,不能吞掉之后产生的版本。 */ + @Update(""" + UPDATE ll_delivery_facts_changed SET state='PROCESSED',processed_at=#{now},last_error_code=NULL + WHERE id=#{id} AND dispatch_id=#{dispatchId} AND state='PENDING' + """) + int acknowledge(@Param("id") long id, @Param("dispatchId") long dispatchId, + @Param("now") LocalDateTime now); + + /** 汇总回滚后以独立短事务记录退避,不改成已完成。 */ + @Update(""" + UPDATE ll_delivery_facts_changed + SET attempt_count=attempt_count+1,last_error_code=#{errorCode},next_attempt_at=GREATEST(next_attempt_at,#{nextAttemptAt}) + WHERE id=#{id} AND state='PENDING' + """) + int failed(@Param("id") long id, @Param("errorCode") String errorCode, + @Param("nextAttemptAt") LocalDateTime nextAttemptAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java index e722a2460..b71495f50 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java @@ -12,7 +12,7 @@ import java.util.List; @Mapper public interface DeliveryOperationMapper extends BaseMapper { - /** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */ + /** 普通流水与原箱凭据证明退回,共享任务按真实扫码及逐票客户归属核验。 */ @Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " + "l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " + "JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " + @@ -25,7 +25,7 @@ public interface DeliveryOperationMapper extends BaseMapper { "AND di.warehouse_pallet_id=oi.warehouse_pallet_id " + "AND EXISTS (SELECT 1 FROM ll_dispatch_pda_pick p JOIN ll_dispatch_pda_pick_order a " + "ON a.customer_id=p.customer_id AND a.pick_id=p.id " + - "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "JOIN ll_dispatch_pda_task t ON t.id=p.task_id " + "WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " + "AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " + "WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " + 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 index 9b949b501..9caf75f13 100644 --- 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 @@ -9,6 +9,18 @@ import java.util.List; @Mapper public interface DeliveryOrderMapper extends BaseMapper { + /** 整车签收事实使用完整真实客户单据集合。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id") + List findAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 取得全部出库锁后一次取得全部派送锁,禁止逐客户混取来源锁。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id FOR UPDATE") + List lockAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 服务内签收信号按整车及派送号定位真实客户,不接受浏览器客户范围。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_no=#{dispatchNo} AND delivery_no=#{deliveryNo}") + List findForSignal(@Param("dispatchNo") String dispatchNo, @Param("deliveryNo") String deliveryNo); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id") List findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java index d83da8796..ccfb38ccf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java @@ -18,18 +18,22 @@ public interface DeliverySourceEvidenceMapper { "WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND id=#{orderId} FOR UPDATE") Source lockSource(@Param("customerId") String customerId, @Param("orderId") long orderId); + /** 从本客户真实扫码及逐票归属定位共享整车任务,不依赖不存在的任务头客户。 */ @Select("SELECT DISTINCT t.dispatch_order_id FROM ll_dispatch_pda_pick_order a " + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + - "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "JOIN ll_dispatch_pda_task t ON t.id=p.task_id " + "WHERE a.customer_id=#{customerId} AND a.order_id=#{orderId} ORDER BY t.dispatch_order_id") List dispatchIds(@Param("customerId") String customerId, @Param("orderId") long orderId); @Select("SELECT d.id AS delivery_id,d.dispatch_order_id AS dispatch_id,d.delivery_group_key AS group_key," + - "d.package_count AS packages,d.signed_packages,d.signed_at,ds.depart_at AS departed_at FROM ll_delivery_order d " + - "LEFT JOIN ll_dispatch_order ds ON ds.customer_id=d.customer_id AND ds.id=d.dispatch_order_id " + + "d.package_count AS packages,d.signed_packages,d.signed_at,ds.departed_at AS departed_at FROM ll_delivery_order d " + + "LEFT JOIN ll_dispatch_order ds ON ds.id=d.dispatch_order_id " + + "AND EXISTS (SELECT 1 FROM ll_dispatch_item di_member WHERE di_member.dispatch_order_id=ds.id AND di_member.customer_id=d.customer_id) " + "WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY d.id") + /** 头部不再代表单客户;由真实货物客户成员证明派送归属,保持单据客户过滤。 */ List deliveries(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); + /** 共享任务下只读取同一真实客户的扫码、订单和回仓凭据,卸货组沿原权威规则生成。 */ @Select("SELECT a.id AS attribution_id,t.dispatch_order_id AS dispatch_id," + "CASE WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL " + "THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) " + @@ -38,7 +42,7 @@ public interface DeliverySourceEvidenceMapper { "WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) ELSE NULL END AS group_key," + "a.order_id,a.packages,a.receipt_id,a.return_receipt_id,r.inventory_ledger_id AS return_ledger_id,r.received_packages AS return_packages FROM ll_dispatch_pda_pick_order a " + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + - "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "JOIN ll_dispatch_pda_task t ON t.id=p.task_id " + "LEFT JOIN ll_dispatch_item di ON di.customer_id=p.customer_id AND di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id " + "LEFT JOIN ll_return_stock_receipt r ON r.customer_id=a.customer_id AND r.id=a.return_receipt_id AND r.order_id=a.order_id " + "AND EXISTS (SELECT 1 FROM ll_inventory_ledger rl JOIN ll_exception_order re ON re.customer_id=rl.customer_id " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java new file mode 100644 index 000000000..9e7ed734b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baiyue.lastleg.delivery.entity.DispatchDeliveryArrangement; +import org.apache.ibatis.annotations.*; + +/** 整车唯一安排事实的持久化,不以客户出库头替代整车身份。 */ +@Mapper +public interface DispatchDeliveryArrangementMapper { + /** 普通读组合预览中的当前安排事实。 */ + @Select("SELECT * FROM ll_dispatch_delivery_arrangement WHERE dispatch_order_id=#{dispatchId}") + DispatchDeliveryArrangement find(@Param("dispatchId") long dispatchId); + + /** 调用方先持共同父锁,再读取唯一安排。 */ + @Select("SELECT * FROM ll_dispatch_delivery_arrangement WHERE dispatch_order_id=#{dispatchId} FOR UPDATE") + DispatchDeliveryArrangement lock(@Param("dispatchId") long dispatchId); + + /** 单车、安排号及首次请求键均由唯一约束防重复。 */ + @Insert(""" + INSERT INTO ll_dispatch_delivery_arrangement + (id,dispatch_order_id,dispatch_no,arrangement_key,business_hash,cargo_hash,loading_completion_hash, + original_command_key,original_operator_id,result_json,cargo_json,created_at) + VALUES (#{id},#{dispatchOrderId},#{dispatchNo},#{arrangementKey},#{businessHash},#{cargoHash}, + #{loadingCompletionHash},#{originalCommandKey},#{originalOperatorId},#{resultJson},#{cargoJson},#{createdAt}) + """) + int insert(DispatchDeliveryArrangement arrangement); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java index 1e1690c19..14d07321e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java @@ -17,7 +17,7 @@ public interface DispatchFeeCargoMapper { JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=di.customer_id LEFT JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=di.customer_id LEFT JOIN (SELECT pk.dispatch_item_id,pk.inventory_position_id,SUM(pk.packages) AS packages - FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id AND task.customer_id=pk.customer_id + FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id WHERE task.dispatch_order_id=#{dispatchId} AND pk.customer_id=#{customerId} GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id 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 index a71e718ce..c560d427c 100644 --- 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 @@ -9,6 +9,14 @@ import java.util.List; @Mapper public interface OutboundOrderMapper extends BaseMapper { + /** 整车跨客户读取全部出库单,不用第一张代表全车。 */ + @Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id") + List findAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 父锁后先收齐所有客户出库锁,再锁派送和来源。 */ + @Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id FOR UPDATE") + List lockAllByDispatch(@Param("dispatchId") long dispatchId); + @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 outbound_no=#{outboundNo} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java new file mode 100644 index 000000000..db94a64a1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Destination; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.util.*; + +/** 整车按真实客户与目的地拆分,物理托盘和来源行分别统计。 */ +@Component +public class DeliveryCargoGroups { + private final ObjectMapper json; + + /** 使用服务统一序列化器计算稳定业务摘要。 */ + public DeliveryCargoGroups(ObjectMapper json) { this.json=json; } + + /** 本域分组结果不作为另一套公网DTO或跨域Entity。 */ + public record Group(String key,String customerId,String customerName,Destination destination, + String cargoHash,List cargo,long packages,long palletCount) { } + + /** 只接受完整真实拣货来源,不从展示入仓码或计划数补齐事实。 */ + public List groups(Context context) { + if (context==null || context.counts()==null || context.cargo()==null || context.cargo().isEmpty()) + throw conflict("排车单没有真实待派送货物"); + var groups=new TreeMap>(); + var attributions=new HashSet(); + var palletDestinations=new HashMap(); + for (var row:context.cargo()) { + if (row==null || row.dispatchItemId()==null || row.dispatchItemId()<=0 || blank(row.customerId()) || "0".equals(row.customerId()) + || row.orderId()==null || row.orderId()<=0 || row.packages()<=0 || row.pickId()==null || row.pickId()<=0 || row.attributionId()==null || row.attributionId()<=0 + || blank(row.sourceKey()) || row.destination()==null || blank(row.destination().key())) + throw conflict("货物缺少真实客户、拣货或来源归属"); + if ("RETURN_RELABEL".equals(row.sourceStage()) + ? row.returnCartonId()==null || row.returnCartonId()<=0 + : row.inventoryPositionId()==null || row.inventoryPositionId()<=0) + throw conflict("本次拣货缺少实际库存仓位或退货箱来源"); + if (!attributions.add(row.attributionId())) throw conflict("本次派送包含重复拣货来源"); + if (blank(row.destination().type()) || !Set.of("FBA","PRIVATE").contains(row.destination().type())) + throw conflict("货物派送目的地类型不完整"); + if (row.warehousePalletId()!=null) { + var previous=palletDestinations.putIfAbsent(row.warehousePalletId(),row.destination().key()); + if (previous!=null && !previous.equals(row.destination().key())) throw conflict("同一实物托盘不同目的地须先拆托"); + } + if ("FBA".equals(row.destination().type()) && blank(row.destination().warehouseCode()) + || "PRIVATE".equals(row.destination().type()) && blank(row.destination().address())) + throw conflict("货物缺少真实目的仓或派送地址"); + groups.computeIfAbsent(groupKey(row.customerId(),row.destination()),ignored->new ArrayList<>()).add(row); + } + long total=context.cargo().stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact); + if (!context.pickingReconciled() || total!=context.counts().packageCount() + || total!=context.counts().pickedPackageCount() && total!=context.counts().shippedPackageCount()) + throw conflict("实际拣货来源与本车箱数尚未核平"); + var result=new ArrayList(); + for (var entry:groups.entrySet()) { + var rows=entry.getValue().stream().sorted(Comparator.comparing(CargoLine::dispatchItemId) + .thenComparing(CargoLine::attributionId)).toList(); + var first=rows.get(0); + if (rows.stream().anyMatch(row->!Objects.equals(row.destination(),first.destination()))) + throw conflict("同组货物的目的地信息不一致,请核对后安排"); + long packages=rows.stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact); + long pallets=rows.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count(); + result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),first.destination(), + hash(rows),rows,packages,pallets)); + } + return List.copyOf(result); + } + + /** 客户是组身份的一部分,同目的仓的其他客户永不合单。 */ + public String groupKey(String customerId,Destination destination) { + return hash(List.of(customerId,destination.key())); + } + + /** 本地派送来源证明沿用单据分组键,明确映射既有FBA和私人地址语义。 */ + public String documentGroupKey(Destination destination) { + if ("PRIVATE".equals(destination.type()) && !blank(destination.address())) + return "PRIVATE:"+destination.address().trim()+":"+Objects.toString(destination.postalCode(),"").trim(); + if ("FBA".equals(destination.type()) && !blank(destination.warehouseCode())) + return "CODE:"+destination.warehouseCode().trim().toUpperCase(Locale.ROOT); + throw conflict("派送目的地缺少真实业务标识"); + } + + /** 摘要仅使用规范化领域值,不包含操作者或偶然集合遍历顺序。 */ + public String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); } + catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { + throw new IllegalStateException("派送业务摘要生成失败",error); + } + } + + /** 缺失业务事实直接阻断,不用内部编号回退展示。 */ + private static boolean blank(String value) { return value==null || value.isBlank(); } + /** 业务冲突不产生部分单据。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java new file mode 100644 index 000000000..3d59a40c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import org.springframework.stereotype.Component; +import java.time.Instant; +import java.util.*; + +/** 五阶段只接受原实发全部正常签收,不将真实退回当作签收数量。 */ +@Component +public class NormalDeliveryCompletionPolicy { + private final DeliveryCargoGroups hashes; + + /** 复用本域确定性摘要器,不申请历史来源订单锁。 */ + public NormalDeliveryCompletionPolicy(DeliveryCargoGroups hashes) { this.hashes=hashes; } + + /** 按已安排单据精确集合和原实发数量核对,空集合不算完成。 */ + public NormalDeliveryFacts evaluate(Context context,ArrangementResult arrangement,List actual,Instant now) { + var reasons=new ArrayList(); + var expected=arrangement==null?List.of():arrangement.deliveries(); + var seen=new HashMap(); + if (context.departedAt()==null || now==null || context.departedAt().isAfter(now)) reasons.add("尚无有效实际发车事实"); + if (expected==null || expected.isEmpty()) reasons.add("缺少本车已安排派送单"); + for (var fact:actual) if (fact==null || fact.deliveryNo()==null || seen.putIfAbsent(fact.deliveryNo(),fact)!=null) + reasons.add("派送事实缺失或重复"); + var expectedNos=expected==null?List.of():expected.stream().map(DeliveryReference::deliveryNo).sorted().toList(); + if (new HashSet<>(expectedNos).size()!=expectedNos.size() || !seen.keySet().equals(new HashSet<>(expectedNos))) + reasons.add("派送单未精确覆盖本车安排"); + long total=0; Instant completedAt=null; + if (expected!=null) for (var reference:expected) { + var fact=seen.get(reference.deliveryNo()); + if (fact==null) continue; + if (!Objects.equals(fact.customerId(),reference.customerId()) || !Objects.equals(fact.groupKey(),reference.groupKey()) + || !Objects.equals(fact.outboundNo(),reference.outboundNo()) || fact.originalPackages()!=reference.packageCount()) + reasons.add("派送客户、目的地或原实发来源不一致"); + total=Math.addExact(total,fact.originalPackages()); + // full签收字段是原运输事实;签后另退不重开,但签剩余/拒收/无签收永不冒充原全签。 + if (!fact.outboundCompleted() || fact.status()==null || !Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(fact.status()) || fact.originalPackages()<=0 + || fact.signedPackages()!=fact.originalPackages() || fact.signedAt()==null) + reasons.add("仍有货物未正常全部签收,异常或退回不能代替签收"); + else if (context.departedAt()==null || fact.signedAt().isBefore(context.departedAt()) + || now==null || fact.signedAt().isAfter(now)) reasons.add("签收时间与真实发车事实不一致"); + else if (completedAt==null || fact.signedAt().isAfter(completedAt)) completedAt=fact.signedAt(); + } + if (total<=0 || context.counts()==null || total!=context.counts().shippedPackageCount()) + reasons.add("签收单据未完整覆盖本趟真实实发箱数"); + var sorted=actual.stream().filter(Objects::nonNull).sorted(Comparator.comparing(NormalDeliveryFact::customerId,Comparator.nullsFirst(String::compareTo)) + .thenComparing(NormalDeliveryFact::deliveryNo,Comparator.nullsFirst(String::compareTo))).toList(); + boolean normal=reasons.isEmpty(); + return new NormalDeliveryFacts(context.dispatchNo(),hashes.hash(Arrays.asList(context.dispatchNo(),expectedNos,sorted)), + normal,normal?completedAt:null,expectedNos,sorted,reasons.stream().distinct().toList()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java new file mode 100644 index 000000000..0cd12d693 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +/** 未执行出库取消的数据访问,业务资格与费用撤销仍由Service编排。 */ +@Repository +public class DeliveryCancellationRepository { + private final OutboundOrderMapper outbounds; + private final OutboundPalletItemMapper items; + private final DeliveryOrderMapper deliveries; + private final OutboundPdaTaskMapper tasks; + private final DeliveryOperationMapper operations; + private final RelabelApplicationMapper relabels; + private final ReturnInventoryCartonMapper cartons; + private final WarehouseTransferOrderMapper transfers; + private final LastlegOrderMapper sources; + private final LastlegOrderStatusHistoryMapper histories; + + /** 装配原领域Mapper,不能将SQL搬到取消Service。 */ + public DeliveryCancellationRepository(OutboundOrderMapper outbounds,OutboundPalletItemMapper items,DeliveryOrderMapper deliveries, + OutboundPdaTaskMapper tasks,DeliveryOperationMapper operations,RelabelApplicationMapper relabels, + ReturnInventoryCartonMapper cartons,WarehouseTransferOrderMapper transfers,LastlegOrderMapper sources,LastlegOrderStatusHistoryMapper histories) { + this.outbounds=outbounds;this.items=items;this.deliveries=deliveries;this.tasks=tasks;this.operations=operations; + this.relabels=relabels;this.cartons=cartons;this.transfers=transfers;this.sources=sources;this.histories=histories; + } + /** 先普通定位真实父身份。 */ + public OutboundOrder find(String no) { return outbounds.findGlobalByNo(no); } + /** 父级协调完成后才申请出库锁。 */ + public OutboundOrder lock(String no) { return outbounds.lockGlobalByNo(no); } + /** 独立换标出库保留其原域确认锁。 */ + public RelabelApplication lockRelabel(OutboundOrder order) { return relabels.lockByOutbound(order.getCustomerId(),order.getOutboundNo()); } + /** 仅撤销本次出库关联,原生箱和普通仓托均保留客户已确认成果;旧号必须显式清空。 */ + public boolean restoreRelabelArrangement(RelabelApplication application,String outboundNo,String fulfillment,LocalDateTime now) { + return relabels.update(null,new LambdaUpdateWrapper() + .eq(RelabelApplication::getId,application.getId()).eq(RelabelApplication::getCustomerId,application.getCustomerId()) + .eq(RelabelApplication::getOutboundOrderNo,outboundNo).eq(RelabelApplication::getStatus,"READY_FOR_OUTBOUND") + .set(RelabelApplication::getOutboundOrderNo,null).set(RelabelApplication::getFulfillmentStatus,fulfillment) + .set(RelabelApplication::getUpdatedAt,now))==1; + } + /** 子明细始终按出库客户读取。 */ + public List items(OutboundOrder order) { return items.findByOutbound(order.getCustomerId(),order.getId()); } + /** 一次取得本出库全部派送锁,不边持来源锁边锁下一张派送。 */ + public List deliveries(OutboundOrder order) { return deliveries.lockByOutbound(order.getCustomerId(),order.getId()); } + /** 退货实际箱必须在原域锁内核对。 */ + public ReturnInventoryCarton lockCarton(String mark) { return cartons.lockMark(mark); } + /** 只释放尚未执行的预留箱,不生成退库流水。 */ + public void save(ReturnInventoryCarton value) { cartons.updateById(value); } + /** 更新已锁派送取消状态。 */ + public void save(DeliveryOrder value) { deliveries.updateById(value); } + /** PDA任务与出库同事务取消。 */ + public OutboundPdaTask task(OutboundOrder value) { return tasks.lockByOutbound(value.getCustomerId(),value.getId()); } + /** 保存已锁任务状态。 */ + public void save(OutboundPdaTask value) { tasks.updateById(value); } + /** 保存已锁出库状态。 */ + public void save(OutboundOrder value) { outbounds.updateById(value); } + /** 保存尚未执行的出库明细状态。 */ + public void save(OutboundPalletItem value) { items.updateById(value); } + /** 锁原转仓单以恢复其明确业务状态。 */ + public WarehouseTransferOrder lockTransfer(String customer,String no) { return transfers.lockNo(customer,no); } + /** 保存转仓恢复状态。 */ + public void save(WarehouseTransferOrder value) { transfers.updateById(value); } + /** 相关来源在费用锁前以数字ID顺序锁定。 */ + public LastlegOrder lockSource(String customer,long id) { return sources.lockById(customer,id); } + /** 来源候选一次读齐并固定顺序,不能按展示入仓码挑一票。 */ + public List sources(String customer,String outboundNo) { + return sources.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId,customer) + .eq(LastlegOrder::getOutboundOrderNo,outboundNo).orderByAsc(LastlegOrder::getId)); + } + /** 按真实转换历史恢复,而非统一写某个旧状态。 */ + public LastlegOrderStatusHistory previous(String customer,long id,List events) { + return histories.selectOne(new LambdaQueryWrapper().eq(LastlegOrderStatusHistory::getCustomerId,customer) + .eq(LastlegOrderStatusHistory::getOrderId,id).in(LastlegOrderStatusHistory::getEventId,events) + .orderByAsc(LastlegOrderStatusHistory::getCreatedAt,LastlegOrderStatusHistory::getId).last("LIMIT 1")); + } + /** 显式清空业务关联并增加版本,防止MyBatis跳过null留下旧单号。 */ + public boolean restore(LastlegOrder value,String outboundNo,String status,String subStatus,LocalDateTime now) { + return sources.update(null,new LambdaUpdateWrapper().eq(LastlegOrder::getId,value.getId()) + .eq(LastlegOrder::getCustomerId,value.getCustomerId()).eq(LastlegOrder::getOutboundOrderNo,outboundNo) + .set(LastlegOrder::getCurrentStatus,status).set(LastlegOrder::getSubOrderStatus,subStatus) + .set(LastlegOrder::getOutboundOrderNo,null).set(LastlegOrder::getDeliveryOrderNo,null) + .set(LastlegOrder::getVersion,Objects.requireNonNullElse(value.getVersion(),0)+1).set(LastlegOrder::getUpdatedAt,now))==1; + } + /** 保存不可变取消历史。 */ + public void history(LastlegOrderStatusHistory value) { histories.insert(value); } + /** 完整原因由业务层分段保存,不截断丢失。 */ + public void operation(DeliveryOperation value) { operations.insert(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java new file mode 100644 index 000000000..3dcc6318a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; + +/** 派送资料及签收关联的数据访问,业务事务和来源证明由Service编排。 */ +@Repository +public class DeliveryDocumentRepository { + private final DeliveryOrderMapper orders; + private final OutboundOrderMapper outbounds; + private final DeliveryDocumentRevisionMapper revisions; + private final DeliveryOperationMapper operations; + private final LastlegOrderMapper sources; + private final DeliverySourceEvidenceMapper evidence; + + /** 装配各表Mapper,不在资料服务直接访问持久化接口。 */ + public DeliveryDocumentRepository(DeliveryOrderMapper orders, OutboundOrderMapper outbounds, + DeliveryDocumentRevisionMapper revisions, DeliveryOperationMapper operations, + LastlegOrderMapper sources, DeliverySourceEvidenceMapper evidence) { + this.orders=orders; this.outbounds=outbounds; this.revisions=revisions; + this.operations=operations; this.sources=sources; this.evidence=evidence; + } + + /** 客户和派送号共同定位资料单。 */ + public DeliveryOrder find(String customerId, String no) { return orders.findByNo(customerId,no); } + /** 签收先锁自己的派送,随后才能申请来源订单锁。 */ + public DeliveryOrder lock(String customerId, String no) { return orders.lockByNo(customerId,no); } + /** 出库事实普通读,避免在派送锁之后反向申请出库锁。 */ + public OutboundOrder outbound(String customerId, String no) { return outbounds.findByNo(customerId,no); } + /** 读取当前资料命令以保持原请求重放语义。 */ + public DeliveryDocumentRevision command(String customerId,long id,String key) { return revisions.findCommand(customerId,id,key); } + /** 保存已校验的资料和签收字段。 */ + public void save(DeliveryOrder order) { orders.updateById(order); } + /** 同事务保存不可变资料版本。 */ + public void revision(DeliveryDocumentRevision revision) { revisions.insert(revision); } + /** 同事务保存业务历史。 */ + public void operation(DeliveryOperation operation) { operations.insert(operation); } + /** 读取资料版本历史,不暴露其他客户附件。 */ + public List history(String customerId,long id) { return revisions.history(customerId,id); } + /** 签收仍需拒绝未完成的真实退回流程。 */ + public long activeReturns(String customerId,long id) { return operations.activeReturns(customerId,id); } + /** 只读取本派送的真实退回入账事实。 */ + public List returns(String customerId,long id) { return operations.returnReceipts(customerId,id); } + /** 拣货来源按真实客户与整车读取,不拿当前订单指针替代历史。 */ + public List picks(String customerId,long dispatchId) { return evidence.picks(customerId,dispatchId); } + /** 批量读取候选来源,再由Service复核客户归属。 */ + public List sourceOrders(Collection ids) { return ids.isEmpty()?List.of():sources.selectBatchIds(ids); } + /** 非头程本地单据仍读取其本客户的既有当前关系。 */ + public List locallyLinked(String customerId,String deliveryNo) { + return sources.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId,customerId) + .eq(LastlegOrder::getDeliveryOrderNo,deliveryNo)); + } + /** 来源锁由证明服务持有后重新读取当前行,避免旧快照覆盖。 */ + public LastlegOrder source(long id) { return sources.selectById(id); } + /** 只保存Service根据真实来源签收证明得到的子状态。 */ + public void saveSource(LastlegOrder order) { sources.updateById(order); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java new file mode 100644 index 000000000..cfdf3756d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import com.baiyue.lastleg.delivery.mapper.DeliveryFactsChangedMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 信号持久化由派送域维护,主流程经正式端口消费。 */ +@Repository +public class DeliveryFactsChangedRepository { + private final DeliveryFactsChangedMapper mapper; + private final DeliveryOrderMapper deliveries; + + /** 装配唯一的MyBatis数据入口。 */ + public DeliveryFactsChangedRepository(DeliveryFactsChangedMapper mapper, DeliveryOrderMapper deliveries) { + this.mapper = mapper; this.deliveries = deliveries; + } + + /** 公共信号端口只认整车和派送号对应的唯一真实单据。 */ + public List deliveries(String dispatchNo, String deliveryNo) { + return deliveries.findForSignal(dispatchNo, deliveryNo); + } + + /** 确认前复核信号原身份,普通读不反向申请父锁。 */ + public DeliveryFactsChanged find(long id) { return mapper.findById(id); } + + /** 保存一次事实并返回当前唯一版本供业务校验。 */ + public DeliveryFactsChanged insertOnce(DeliveryFactsChanged fact) { + mapper.insertOnce(fact); + return mapper.find(fact.getCustomerId(), fact.getDeliveryId(), fact.getDeliveryVersion()); + } + + /** 有界普通扫描,不在此处持有父锁前的子行锁。 */ + public List pending(LocalDateTime now, int limit) { + return mapper.pending(now, limit); + } + + /** 只确认调用方已经核对的指定信号与排车身份。 */ + public int acknowledge(long id, long dispatchId, LocalDateTime now) { + return mapper.acknowledge(id, dispatchId, now); + } + + /** 记录可重试失败且保留PENDING,不能覆盖已成功确认的信号。 */ + public void failed(long id, String code, LocalDateTime nextAttemptAt) { + mapper.failed(id, code, nextAttemptAt); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java new file mode 100644 index 000000000..47c4cd3bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +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.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 整车派送数据访问;锁顺序明确区分子单和来源,不直写主流程表。 */ +@Repository +public class DispatchDeliveryRepository { + private final DispatchDeliveryArrangementMapper arrangements; + private final OutboundOrderMapper outbounds; + private final OutboundPalletItemMapper items; + private final DeliveryOrderMapper deliveries; + private final DeliveryOperationMapper operations; + private final LastlegOrderMapper sources; + private final LastlegOrderStatusHistoryMapper histories; + + /** 装配派送域唯一持久化入口。 */ + public DispatchDeliveryRepository(DispatchDeliveryArrangementMapper arrangements,OutboundOrderMapper outbounds, + OutboundPalletItemMapper items,DeliveryOrderMapper deliveries,DeliveryOperationMapper operations, + LastlegOrderMapper sources,LastlegOrderStatusHistoryMapper histories) { + this.arrangements=arrangements;this.outbounds=outbounds;this.items=items; + this.deliveries=deliveries;this.operations=operations; + this.sources=sources;this.histories=histories; + } + /** 父锁外预览仅普通读安排事实。 */ + public DispatchDeliveryArrangement arrangement(long id,boolean lock) { return lock?arrangements.lock(id):arrangements.find(id); } + /** 固定顺序取得或读取全部客户出库。 */ + public List outbounds(long id,boolean lock) { return lock?outbounds.lockAllByDispatch(id):outbounds.findAllByDispatch(id); } + /** 仅在取得全体出库锁之后取得全体派送锁。 */ + public List deliveries(long id,boolean lock) { return lock?deliveries.lockAllByDispatch(id):deliveries.findAllByDispatch(id); } + /** 一次安排仅插入一个客户自己的出库头。 */ + public void create(OutboundOrder value) { outbounds.insert(value); } + /** 不可变出库来源明细在同一安排事务保存。 */ + public void create(OutboundPalletItem value) { items.insert(value); } + /** 每客户目的地分别保存派送单。 */ + public void create(DeliveryOrder value) { deliveries.insert(value); } + /** 仅更新已锁定并复验的客户出库执行事实。 */ + public void save(OutboundOrder value) { outbounds.updateById(value); } + /** 仅更新已锁定并复验的客户派送执行事实。 */ + public void save(DeliveryOrder value) { deliveries.updateById(value); } + /** 最后保存单车唯一安排,失败整体回滚不留半车。 */ + public void create(DispatchDeliveryArrangement value) { arrangements.insert(value); } + /** 保存真实业务操作事实,不将零约定冒充费用。 */ + public void operation(DeliveryOperation value) { operations.insert(value); } + /** 正常完成只普通读当前异常数量,不反向锁历史来源。 */ + public long activeReturns(DeliveryOrder value) { return operations.activeReturns(value.getCustomerId(),value.getId()); } + /** 只读取本派送真实退回流水。 */ + public List returns(DeliveryOrder value) { + return operations.returnReceipts(value.getCustomerId(),value.getId()); + } + /** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */ + public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); } + /** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */ + public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now) { + if (sources.update(null,new LambdaUpdateWrapper().eq(LastlegOrder::getId,source.getId()) + .eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo) + .set(LastlegOrder::getDeliveryOrderNo,deliveryNo) + .set(LastlegOrder::getSubOrderStatus,"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY") + .set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1) + .set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化"); + } + /** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */ + public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index dc7b541a0..b75ae4269 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -5,12 +5,10 @@ import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; -import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.repository.DeliveryDocumentRepository; import com.baiyue.lastleg.delivery.model.*; import com.baiyue.lastleg.customervisit.service.CustomerVisitService; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -28,20 +26,18 @@ import java.util.*; @Service public class DeliveryDocumentService { @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; - @Resource private DeliveryOrderMapper orders; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private DeliveryDocumentRevisionMapper revisions; - @Resource private DeliveryOperationMapper operations; + @Resource private DeliveryDocumentRepository repository; + @Resource private com.baiyue.lastleg.dispatch.port.DispatchSignalPort signals; @Resource private DeliveryDocumentFileGateway files; @Resource private ObjectMapper objectMapper; - @Resource private LastlegOrderMapper lastlegOrders; @Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox; @Resource private CustomerVisitService customerVisits; @Resource private SourceDeliveryCompletionQuery sourceCompletion; - @Resource private DeliverySourceEvidenceMapper sourceEvidence; + /** 客户和派送号共同定位当前资料。 */ public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } + /** 客户只查看本客户派送的签收类附件,不返回其他资料。 */ public MerchantReceipts merchantReceipts(String customerId, String deliveryNo) { Detail snapshot = detail(customerId, deliveryNo); return new MerchantReceipts(snapshot.deliveryNo(), snapshot.categories().stream() @@ -49,23 +45,25 @@ public class DeliveryDocumentService { .flatMap(category -> category.files().stream()).toList()); } + /** 下载仍复核业务与类别归属,不放开文件公共访问。 */ public Download merchantReceiptDownload(String customerId, String deliveryNo, long fileId) { return download(customerId, deliveryNo, DeliveryDocumentCategory.RECEIPT, fileId, null); } + /** 上传准备不等于资料保存或真实签收完成。 */ public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category, String operatorId, MultipartFile file) { DeliveryOrder order = require(customerId, deliveryNo); return files.upload(customerId, order.getId(), category, operatorId, file); } - /** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */ + /** 先锁本派送并校验全部资料,签收及本地待汇总事实在同一事务提交。 */ @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Detail save(String customerId, String deliveryNo, String operatorId, Save request) { - DeliveryOrder order = orders.lockByNo(customerId, deliveryNo); + DeliveryOrder order = repository.lock(customerId, deliveryNo); if (order == null) throw missing(); String hash = hash(request); - DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey()); + DeliveryDocumentRevision replay = repository.command(customerId, order.getId(), request.idempotencyKey()); if (replay != null) { if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同"); Detail saved = read(replay.getSnapshotJson()); @@ -96,11 +94,11 @@ public class DeliveryDocumentService { if (autoCompleted) { if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED") && !"RETURN_PENDING".equals(order.getStatus())) throw conflict("当前派送状态不允许通过签收文件完成"); - // A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible. - var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo()); + // 派送锁内只普通读出库,未提交的发车不构成真实出库资格,也不反向申请父锁。 + var outbound = repository.outbound(customerId, order.getOutboundNo()); if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送"); - if (operations.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收"); - try { signedPackages = DeliveryCargoQuantities.from(order, operations.returnReceipts(customerId, order.getId())).remaining(); } + if (repository.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收"); + try { signedPackages = DeliveryCargoQuantities.from(order, repository.returns(customerId, order.getId())).remaining(); } catch (IllegalArgumentException error) { throw conflict(error.getMessage()); } if (signedPackages <= 0) throw conflict("本次派送已无待签收货物"); } @@ -113,7 +111,7 @@ public class DeliveryDocumentService { order.setSignedAt(now); order.setSignedPackages(signedPackages); } - orders.updateById(order); + repository.save(order); if (autoCompleted) { var completion = completeRelatedOrders(order, operatorId, now); fleetPool.signed(new SignedDelivery( @@ -121,29 +119,34 @@ public class DeliveryDocumentService { completion.sourcePackages(),operatorId,now); deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant()); customerVisits.ensureForCompletedDelivery(order, now); + // 只写持久信号;主流程提交后另事务汇总,不能在此持子锁回调父流程。 + if (order.getDispatchOrderId() != null) signals.enqueue(order.getDispatchNo(), order.getDeliveryNo(), + order.getVersion(), now.atZone(executionZone).toInstant()); } Detail result = detail(order, false); DeliveryDocumentRevision revision = new DeliveryDocumentRevision(); revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion()); revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result)); - revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision); + revision.setOperatorId(operatorId); revision.setCreatedAt(now); repository.revision(revision); DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类"); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); if (autoCompleted) recordAutomaticCompletion(order, operatorId, now); return result; } + /** 资料历史保持不可变版本并受原客户范围约束。 */ public List history(String customerId, String deliveryNo) { DeliveryOrder order = require(customerId, deliveryNo); - return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(), + return repository.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(), revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList(); } + /** 当前或历史附件必须确实绑定在本派送对应资料版本。 */ public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) { DeliveryOrder order = require(customerId, deliveryNo); Detail snapshot = detail(order, false); if (revisionVersion != null) { - snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion())) + snapshot = repository.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion())) .findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing); } DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category) @@ -152,36 +155,39 @@ public class DeliveryDocumentService { return new Download(file, files.download(customerId, order.getId(), category, fileId)); } public record Download(DeliveryDocumentDtos.File file, byte[] content) { } + /** 业务编号不属于当前客户时按不存在处理。 */ private DeliveryOrder require(String customerId, String deliveryNo) { - DeliveryOrder order = orders.findByNo(customerId, deliveryNo); + DeliveryOrder order = repository.find(customerId, deliveryNo); if (order == null) throw missing(); return order; } + /** 三类资料只读投影不根据附件数量推断库存或整车阶段。 */ private Detail detail(DeliveryOrder order, boolean replayed) { List categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson()); return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(), (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); } + /** 原始单据版本0有效,后续保存按版本增加。 */ private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } private record Completion(List orders, Map proofs, Map sourcePackages) { } + /** 先根据真实拣货收齐来源,再按跨趟来源锁协议证明正常签收。 */ private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) { var related = new TreeMap(); Map sourcePackages = new TreeMap<>(); if (delivery.getDispatchOrderId() != null) { - var actualPicks = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() + var actualPicks = repository.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() .filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())).toList(); for (var pick : actualPicks) sourcePackages.merge(pick.orderId(), pick.packages(), Math::addExact); var ids = actualPicks.stream() .map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList(); - if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) { + if (!ids.isEmpty()) for (var tail : repository.sourceOrders(ids)) { if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致"); related.put(tail.getId(), tail); } } - // Non-dispatch local workflows retain their established relation; never use this to prove a domestic source. - for (var tail : lastlegOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()).eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()))) { + // 本地非排车单保留既有关系,不能把当前指针当作头程全来源签收证明。 + for (var tail : repository.locallyLinked(delivery.getCustomerId(), delivery.getDeliveryNo())) { if (!DeliveryCompletedOutboxService.domestic(tail)) related.putIfAbsent(tail.getId(),tail); else if (!related.containsKey(tail.getId())) pendingCompletion(delivery, operatorId, now, inboundLabel(tail) + ":缺少历史实际拣货来源,未发布整来源签收事件"); @@ -190,10 +196,10 @@ public class DeliveryDocumentService { .map(LastlegOrder::getSourceInboundId).distinct().toList(); var proofs = sourceIds.isEmpty() ? Map.of() : sourceCompletion.proveAll(delivery.getCustomerId(),sourceIds); - // All source locks are now held. Re-read current rows instead of overwriting with pre-lock snapshots. + // 来源锁已按证明协议持有,重新读取当前行,不能用加锁前快照覆盖并发签收。 var current = new ArrayList(); for (long id : related.keySet()) { - var tail = lastlegOrders.selectById(id); + var tail = repository.source(id); if (tail == null || !delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源已变化"); if (DeliveryCompletedOutboxService.domestic(tail)) { var proof = proofs.get(tail.getSourceInboundId()); @@ -205,38 +211,47 @@ public class DeliveryDocumentService { } } else tail.setSubOrderStatus(Objects.equals(delivery.getSignedPackages(),delivery.getPackageCount()) ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); - tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail); + tail.setUpdatedAt(now); repository.saveSource(tail); current.add(tail); } return new Completion(List.copyOf(current),proofs,Map.copyOf(sourcePackages)); } + /** 入仓业务号缺失明确显示空值标记,不显示内部ID。 */ private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); } + /** 未核齐的来源只保留诊断事实,不伪造全来源完成。 */ private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) { var operation = new DeliveryOperation(); operation.setCustomerId(delivery.getCustomerId()); operation.setDeliveryOrderId(delivery.getId()); operation.setOperatorId(operatorId); operation.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); operation.setResult(reason); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); } + /** 保存本次实际签收箱数,真实退回不得混入签收数量。 */ private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setOperationNode("RECEIPT_AUTO_COMPLETE"); operation.setResult("签收文件保存成功,本次实际签收" + order.getSignedPackages() + "箱;原实发" + order.getPackageCount() + "箱"); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); } + /** 资料原请求以严格载荷摘要重放,版本和文件集合不得改写。 */ private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } + /** 资料与操作结果必须可持久化,否则同事务回滚。 */ private String write(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); } } + /** 损坏历史直接阻断,不回退为当前资料成功。 */ private Detail read(String value) { try { return objectMapper.readValue(value, Detail.class); } catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); } } + /** 隐藏不存在或不属于本客户的业务资料。 */ private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); } + /** 同版本竞争或业务资格变化要求刷新核对。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + /** 参数范围不合法时不产生任何资料变更。 */ private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java new file mode 100644 index 000000000..bdc630e7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.repository.DeliveryFactsChangedRepository; +import com.baiyue.lastleg.dispatch.port.DispatchSignalPort; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.Instant; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +/** 签收只写持久信号,绝不持派送子锁回调整车完成服务。 */ +@Service +public class DeliveryFactsChangedService implements DispatchSignalPort { + private final DeliveryFactsChangedRepository repository; + private final ObjectMapper json; + + /** 装配事实持久化和确定性序列化。 */ + public DeliveryFactsChangedService(DeliveryFactsChangedRepository repository, ObjectMapper json) { + this.repository = repository; + this.json = json; + } + + /** 直接消费主流程实际骨架,信号仅在原签收事务内持久化。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void enqueue(String dispatchNo, String deliveryNo, long deliveryVersion, Instant occurredAt) { + if (blank(dispatchNo) || blank(deliveryNo) || occurredAt == null) throw new IllegalArgumentException("签收信号身份不完整"); + var rows = repository.deliveries(dispatchNo, deliveryNo); + if (rows.size() != 1 || !Objects.equals(rows.get(0).getVersion(), deliveryVersion)) + throw new IllegalStateException("签收信号与当前真实派送版本不一致"); + signed(rows.get(0), LocalDateTime.ofInstant(occurredAt, ZoneId.systemDefault())); + } + + /** 有界普通读取候选,不在父汇总事务前领取或锁定信号。 */ + @Override + @Transactional(readOnly = true) + public List due(Instant now, int limit) { + if (now == null || limit < 1 || limit > 200) throw new IllegalArgumentException("信号扫描范围不合法"); + return repository.pending(LocalDateTime.ofInstant(now, ZoneId.systemDefault()), limit).stream() + .map(f -> new Signal(f.getId(),f.getDispatchNo(),f.getDeliveryNo(),f.getDeliveryVersion(), + f.getAttemptCount(),f.getNextAttemptAt().atZone(ZoneId.systemDefault()).toInstant())).toList(); + } + + /** 与主阶段CAS共同提交指定信号确认,不能提前确认后来产生的版本。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void acknowledge(Signal signal, Instant acknowledgedAt) { + var stored = requireSignal(signal); + if (acknowledgedAt == null) throw new IllegalArgumentException("信号确认时间不能为空"); + repository.acknowledge(stored.getId(),stored.getDispatchId(),LocalDateTime.ofInstant(acknowledgedAt,ZoneId.systemDefault())); + } + + /** 调用方汇总回滚后独立记录退避,已确认的信号不会退回PENDING。 */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void failed(Signal signal, Instant nextRetryAt, String errorCode) { + var stored = requireSignal(signal); + if (nextRetryAt == null || errorCode == null || !errorCode.matches("[A-Z0-9_]{1,64}")) + throw new IllegalArgumentException("信号重试信息不合法"); + repository.failed(stored.getId(),errorCode,LocalDateTime.ofInstant(nextRetryAt,ZoneId.systemDefault())); + } + + /** 只允许操作扫描时的真实业务身份,不凭一个内部ID修改别的信号。 */ + private DeliveryFactsChanged requireSignal(Signal signal) { + if (signal == null || signal.signalId() == null) throw new IllegalArgumentException("信号不能为空"); + var stored = repository.find(signal.signalId()); + if (stored == null || !Objects.equals(stored.getDispatchNo(),signal.dispatchNo()) + || !Objects.equals(stored.getDeliveryNo(),signal.deliveryNo()) + || !Objects.equals(stored.getDeliveryVersion(),signal.deliveryVersion())) + throw new IllegalStateException("信号原业务身份不一致"); + return stored; + } + + /** 与签收共同提交唯一版本,缺失真实排车身份不得补造信号。 */ + @Transactional(propagation = Propagation.MANDATORY) + public void signed(DeliveryOrder delivery, LocalDateTime now) { + if (delivery.getDispatchOrderId() == null) return; + if (delivery.getId() == null || delivery.getDispatchOrderId() <= 0 || blank(delivery.getCustomerId()) + || blank(delivery.getDispatchNo()) || blank(delivery.getDeliveryNo()) || delivery.getVersion() == null + || delivery.getVersion() < 1 || delivery.getSignedAt() == null || delivery.getSignedPackages() == null + || delivery.getSignedPackages() <= 0 || now == null) throw new IllegalStateException("签收汇总信号缺少真实派送事实"); + var fact = new DeliveryFactsChanged(); + fact.setId(IdWorker.getId()); fact.setCustomerId(delivery.getCustomerId()); + fact.setDispatchId(delivery.getDispatchOrderId()); fact.setDispatchNo(delivery.getDispatchNo()); + fact.setDeliveryId(delivery.getId()); fact.setDeliveryNo(delivery.getDeliveryNo()); + fact.setDeliveryVersion(delivery.getVersion()); fact.setOccurredAt(now); + fact.setFactsHash(hash(List.of(delivery.getCustomerId(), delivery.getDispatchOrderId(), delivery.getDispatchNo(), + delivery.getId(), delivery.getDeliveryNo(), delivery.getVersion(), delivery.getSignedPackages(), delivery.getSignedAt()))); + var saved = repository.insertOnce(fact); + if (saved == null || !Objects.equals(saved.getDispatchId(), fact.getDispatchId()) + || !Objects.equals(saved.getDispatchNo(), fact.getDispatchNo()) + || !Objects.equals(saved.getDeliveryNo(), fact.getDeliveryNo()) + || !Objects.equals(saved.getFactsHash(), fact.getFactsHash())) + throw new IllegalStateException("同一签收版本对应的持久事实不一致"); + } + + /** 以规范化真实字段生成摘要,不保存整车完成的推断结论。 */ + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json.writeValueAsBytes(value))); } + catch (NoSuchAlgorithmException | JsonProcessingException error) { throw new IllegalStateException("签收信号摘要生成失败", error); } + } + + /** 业务身份缺失直接拒绝,不使用内部ID代替业务编号。 */ + private static boolean blank(String value) { return value == null || value.isBlank(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java deleted file mode 100644 index be764c7ec..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.baiyue.lastleg.delivery.service; - -import com.baiyue.lastleg.client.FinanceFeeCoverageClient; -import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; -import com.baiyue.lastleg.delivery.dto.DeliveryRequests; -import java.time.Instant; -import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper; -import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.fasterxml.jackson.databind.ObjectMapper; -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.web.server.ResponseStatusException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.*; -import java.util.stream.Collectors; - -@Service -public class DeliveryFeeCoverageService { - private final DispatchOrderMapper dispatches; - private final DispatchItemMapper items; - private final DispatchFeeCargoMapper sources; - private final FinanceFeeCoverageClient finance; - private final ObjectMapper json; - private final String token; - public DeliveryFeeCoverageService(DispatchOrderMapper dispatches, DispatchItemMapper items, DispatchFeeCargoMapper sources, - FinanceFeeCoverageClient finance, ObjectMapper json, @Value("${baiyue.security.internal-service-token}") String token) { - this.dispatches=dispatches; this.items=items; this.sources=sources; this.finance=finance; this.json=json; this.token=token; - } - - @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) - public DeliveryFeeCoverageModels.Preview preview(String dispatchNo) { - var dispatch = dispatches.findGlobalByDispatchNo(dispatchNo.trim()); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()); - var cargo = sources.list(dispatch.getCustomerId(), dispatch.getId()); - String reason = incomplete(selected, cargo); - var inboundIds = new TreeSet(); - if (reason == null) for (var row : cargo) if ("FIRSTLEG".equals(row.sourceRegion())) { - try { long id = Long.parseLong(row.sourceInboundId()); if (id < 1) throw new NumberFormatException(); inboundIds.add(id); } - catch (RuntimeException invalid) { reason = "货物缺少原头程入仓单关联,请先核对来源"; break; } - } - List fees = List.of(); - if (reason == null && !inboundIds.isEmpty()) fees = load(dispatch.getCustomerId(), List.copyOf(inboundIds)); - try { - String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( - json.writeValueAsString(List.of(dispatch.getDispatchNo(), dispatch.getCustomerId(), cargo, - selected.stream().map(item -> Arrays.asList(item.getId(), item.getPackageCount(), item.getOverseasWarehouseId(), - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), - item.getDeliveryAddress(), item.getPostalCode())).toList())).getBytes(StandardCharsets.UTF_8))); - return new DeliveryFeeCoverageModels.Preview(dispatch.getDispatchNo(), reason == null, reason, hash, cargo, fees); - } catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException(error); } - } - private List load(String customerId, List inboundIds) { - try { - var result = finance.sources(token, new FinanceFeeCoverageClient.Query(Long.valueOf(customerId), inboundIds)); - if (result == null || !result.successful() || result.data() == null) throw unavailable(); - if (result.data().stream().anyMatch(fee -> fee.financeFeeId() == null || fee.financeFeeId() < 1 || fee.sourceFeeId() == null - || !Objects.equals(fee.customerId(), Long.valueOf(customerId)) || fee.sourceVersion() < 1 - || !inboundIds.contains(fee.sourceInboundId()) || fee.sourceHash() == null || fee.sourceHash().isBlank() - || fee.amount() == null || fee.amount().signum() < 0 || !"CNY".equals(fee.currency()) - || !Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(fee.sourceType()) - || !Set.of("AVAILABLE", "SETTLED").contains(fee.status()))) throw unavailable(); - return result.data(); - } catch (RuntimeException error) { throw unavailable(); } - } - private static String incomplete(List selected, List cargo) { - if (selected.isEmpty()) return "排车单没有待发运货物"; - var totals = cargo.stream().collect(Collectors.groupingBy(DeliveryFeeCoverageModels.Cargo::dispatchItemId, - Collectors.summingLong(DeliveryFeeCoverageModels.Cargo::packages))); - var ids = selected.stream().map(DispatchItem::getId).collect(Collectors.toSet()); - if (!ids.equals(totals.keySet())) return "实际货物来源尚未齐全,请先完成收货并核对实物托盘"; - for (var item : selected) if (item.getPackageCount() == null || totals.get(item.getId()).longValue() != item.getPackageCount()) - return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围"; - return null; - } - public record Decision(Set includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set externalInboundCodes) { - public static Decision none() { return new Decision(Set.of(), null, Set.of()); } - public List uncovered(String service) { - if (evidence == null) return List.of(); - return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalInboundCodes)).toList(); - } - } - /** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */ - public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set externalInboundCodes) { - if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none(); - var preview = preview(dispatchNo); - if (!preview.sourceComplete()) throw conflict(preview.blockReason()); - if (!Objects.equals(preview.cargoHash(), request.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化,请重新核对覆盖范围"); - var references = new ArrayList(); - var scopes = new HashSet(); - for (var selection : request.feeCoverage()) { - var fee = preview.feeSources().stream().filter(row -> Objects.equals(row.financeFeeId(), selection.financeFeeId())).findFirst() - .orElseThrow(() -> conflict("原费用不属于本次实际货物,或已审核状态/有效性发生变化")); - if (fee.sourceVersion() != selection.sourceVersion() || !Objects.equals(fee.sourceHash(), selection.sourceHash())) - throw conflict("原费用版本已变化,请重新确认服务覆盖范围"); - var services = new TreeSet<>(selection.services()); - if (services.isEmpty() || !Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE").containsAll(services)) - throw conflict("请选择本票原费用明确包含的服务"); - for (var service : services) if (!scopes.add(fee.sourceInboundId() + ":" + service)) - throw conflict("同一票同一服务只能引用一项原费用"); - references.add(new DeliveryFeeCoverageModels.Reference(fee, List.copyOf(services))); - } - var included = new HashSet(); - for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE")) - if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalInboundCodes))) included.add(service); - return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(), - List.copyOf(references), operator, Instant.now()), Set.copyOf(externalInboundCodes)); - } - private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service, - List references, Set externalInboundCodes) { - if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) return true; - return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference -> - reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service)); - } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } - private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "原费用来源暂不可用,请稍后重试"); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index a8c9e3496..ecd857967 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -1,8 +1,6 @@ package com.baiyue.lastleg.delivery.service; -import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -23,9 +21,9 @@ import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; 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.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderDetail; @@ -94,7 +92,7 @@ import java.util.stream.Collectors; @Service public class DeliveryTransferService { - @Resource private DispatchOrderMapper dispatchOrders; + @Resource private DispatchAggregateRepository dispatches; @Resource private DispatchItemMapper dispatchItems; @Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundPalletItemMapper outboundPallets; @@ -129,283 +127,9 @@ public class DeliveryTransferService { @Resource private RelabelApplicationService relabelService; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private ObjectMapper objectMapper; - @Resource private FleetDispatchBindingGuard fleetBindings; - @Resource private FleetBookingFreight bookingFreight; - @Resource private DeliveryFeeCoverageService feeCoverage; @Resource private DeliveryExecutionGuard deliveryExecution; - @Resource private DispatchCargoSourceQuery cargoSources; - - @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) - public FleetBookingFreight.Preview externalFreight(String dispatchNo) { - DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo)); - if (dispatch == null) throw notFound("Dispatch order not found"); - return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); - } - - /** 外接运费与车次绑定使用相同的实际来源和数量。 */ - private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List items) { - return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), - cargoSources.sources(dispatch, items)); - } - - @Transactional - public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, - DeliveryRequests.Transfer request) { - DispatchOrder dispatch = dispatchOrders.lockGlobalByDispatchNo(trim(dispatchNo)); - if (dispatch == null) throw notFound("Dispatch order not found"); - return transferResolved(dispatch, operatorId, request); - } - - @Transactional - public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId, - DeliveryRequests.Transfer request) { - if (request.feeCoverage() != null && !request.feeCoverage().isEmpty()) - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认"); - DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo); - if (found == null) throw notFound("Dispatch order not found"); - DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId()); - if (dispatch == null) throw notFound("Dispatch order not found"); - return transferResolved(dispatch, operatorId, request); - } - - /** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */ - private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId, - DeliveryRequests.Transfer request) { - String customerId = dispatch.getCustomerId(); - String requestHash = transferRequestHash(request); - 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 replayTransfer(replay, requestHash); - } - OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); - if (existing != null) return replayTransfer(existing, requestHash); - if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { - throw conflict("Dispatch cannot be transferred to delivery in its current status"); - } - fleetBindings.requireMatchingAssignment(dispatch.getId(), - new FleetDispatchGateway.Assignment(dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone()), - new FleetDispatchGateway.Assignment(request.vehiclePlate(), request.driverName(), request.driverPhone())); - dispatchItems.bindReceivedPallets(customerId, dispatch.getId()); - 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"); - var freight = externalFreight(dispatch, items); - if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash())) - && !Objects.equals(freight.snapshotHash(), request.externalFreightHash())) - throw conflict("外接实收或约定运费已变化,请重新打开转派送核对"); - var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet()); - var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none() - : feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalInboundCodes); - validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices()); - if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes()); - - LocalDateTime now = LocalDateTime.now(); - OutboundOrder outbound = new OutboundOrder(); - outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId())); - outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); - outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); - outbound.setLxCodes(resolveLxCodes(customerId, items)); - outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - outbound.setPalletCount(items.size()); - outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg)); - outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg)); - outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm)); - outbound.setIdempotencyKey(request.idempotencyKey().trim()); - outbound.setRequestHash(requestHash); - if (coverage.evidence() != null) { - try { outbound.setFeeCoverageJson(objectMapper.writeValueAsString(coverage.evidence())); } - catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法保存", exception); } - } - 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 replayTransfer(concurrent, requestHash); - } - concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); - if (concurrent != null) { - return replayTransfer(concurrent, requestHash); - } - 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.setGrossWeightKg(source.getGrossWeightKg()); item.setVolumeWeightKg(source.getVolumeWeightKg()); - item.setVolumeCbm(source.getVolumeCbm()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); - if (StringUtils.hasText(source.getCartonMarks())) { - item.setCartonMarks(source.getCartonMarks()); - } else if (source.getWarehousePalletId() != null) { - String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream() - .map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(",")); - item.setCartonMarks(StringUtils.hasText(marks) ? marks : null); - } - item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); - outboundPallets.insert(item); - } - - Map> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy( - this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList())); - List deliveries = new ArrayList<>(); - int groupIndex = 0; - for (Map.Entry> entry : destinationGroups.entrySet()) { - List groupItems = entry.getValue(); - DispatchItem destination = groupItems.get(0); - DeliveryOrder delivery = new DeliveryOrder(); - delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); - delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); - delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); - delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); - delivery.setDestinationWarehouseId(trim(destination.getDestinationWarehouseId())); - delivery.setDestinationWarehouseCode(firstText( - firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), - destination.getDestinationWarehouseId())); - delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); - delivery.setDestinationType(destination.getDestinationType()); - delivery.setDeliveryAddress(destination.getDeliveryAddress()); - delivery.setPostalCode(destination.getPostalCode()); - delivery.setContactName(destination.getContactName()); - delivery.setContactPhone(destination.getContactPhone()); - delivery.setDeliveryGroupKey(entry.getKey()); - LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); - delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); - delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); - delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); - delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); - delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); - delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); - delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); - delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - delivery.setPalletCount(groupItems.size()); - delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm)); - delivery.setTotalWeightKg(sumKnownPhysicalWeight(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); - delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId); - delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); - recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now); - bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); - deliveries.add(delivery); - } - for (var quote : freight.quotes()) { - var destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey( - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(), - item.getDeliveryAddress(), item.getPostalCode()))).findFirst() - .orElseThrow(() -> conflict("外接约定目的地未生成对应派送单")); - recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT", - "外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 " - + quote.currency() + " " + quote.amount().toPlainString(), now); - // An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee. - if (quote.amount().signum() == 0) continue; - feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT", - new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(), - "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(), - null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()), - "DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null, - null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(), - quote.quantityUnit(), quote.unitPrice(), null, null)); - } - DeliveryOrder feeDelivery = deliveries.get(0); - List feeInputs = request.fees() == null ? List.of() : request.fees(); - var ordinaryCargo = freight.requiresDeliveryFee() ? cargoSources.sources(dispatch, items).stream() - .filter(source -> !externalInboundCodes.contains(source.inboundCode())).toList() : List.of(); - for (DeliveryRequests.FeeItem input : feeInputs) { - String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT); - var uncovered = coverage.uncovered(feeCode); - var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet()); - var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction()) - ? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList() - : "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee() - ? items.stream().filter(item -> ordinaryCargo.stream().anyMatch(source -> Objects.equals(source.warehousePalletId(), - item.getWarehousePalletId().toString()) && Objects.equals(source.destinationKey(), FleetDispatchGateway.destinationKey( - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))).toList() : items; - var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty() - ? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow() - : feeDelivery; - LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems); - // 合托后按真实普通来源归属费用;多票时只绑定派送,不借展示入仓码挂到外接货。 - if ("RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && !externalInboundCodes.isEmpty()) { - var ordinaryOrders = cargoSources.ordinaryOrderIds(dispatch, externalInboundCodes); - firstOrder = ordinaryOrders.size() == 1 ? tailOrders.selectById(ordinaryOrders.iterator().next()) : null; - if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致"); - } - if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) { - var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList(); - firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null; - if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致"); - } - feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode), - new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(), - "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(), - firstOrder == null ? null : firstOrder.getId(), null, - firstOrder == null ? null : firstOrder.getInboundCode(), null, - firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()), - firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()), - feeCode, input.feeName().trim(), input.direction(), input.supplierId(), - input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(), - input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(), - input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(), - input.periodStart(), input.periodEnd())); - } - return result(outbound, deliveries, false); - } - - /** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */ - private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) { - if (!requestHash.equals(outbound.getRequestHash())) - throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程"); - return result(outbound, requireDeliveries(outbound.getCustomerId(), outbound.getId()), true); - } - - String transferRequestHash(DeliveryRequests.Transfer request) { - // A new command key cannot change an already transferred dispatch's business content. - com.fasterxml.jackson.databind.node.ObjectNode content = objectMapper.valueToTree(request); - content.remove("idempotencyKey"); - if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) { - content.remove("feeCoverage"); content.remove("feeCoverageCargoHash"); - } - return hash(content); - } - - private void validateTransferFees(DeliveryRequests.Transfer request, boolean requiresDeliveryFee, Set covered) { - List inputs = request.fees() == null ? List.of() : request.fees(); - Set allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"); - Set seen = new HashSet<>(); - for (DeliveryRequests.FeeItem input : inputs) { - String feeType = input.feeType().trim().toUpperCase(Locale.ROOT); - if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType); - if (!seen.add(input.direction() + ":" + feeType)) throw conflict("Duplicate delivery fee type: " + feeType); - if ("RECEIVABLE".equals(input.direction()) && covered.contains(feeType)) throw conflict("本次货物的该服务已由原费用覆盖,请勿重复登记应收"); - if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT))) - throw conflict("Delivery fees only support CNY or USD"); - if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero"); - } - if (requiresDeliveryFee && !covered.contains("DELIVERY_FEE") && !seen.contains("RECEIVABLE:DELIVERY_FEE")) throw conflict("尚未有约定运费的货物须填写派送费"); - if (!requiresDeliveryFee && inputs.stream().anyMatch(item -> "DELIVERY_FEE".equalsIgnoreCase(item.feeType().trim()) - && "RECEIVABLE".equals(item.direction()))) throw conflict("本排车全部货物已带入外接约定运费,请勿重复填写派送应收"); - if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT)) - && !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) { - throw conflict("Loading fee is required for floor or mixed loading"); - } - if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) { - if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment"); - if (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment"); - } - } + @Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; + @Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations; @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { @@ -1164,65 +888,95 @@ public class DeliveryTransferService { } } - @Transactional + /** 先取得真实整车父锁,再锁出库和派送;已扫码不得以取消出库伪造退库。 */ + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) { required(request == null ? null : request.reason(), "取消原因不能为空"); - OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo)); + OutboundOrder observed = cancellations.find(trim(outboundNo)); if (observed == null) throw notFound("Outbound order not found"); if ("DISCARD_OUTBOUND".equals(observed.getOutboundType())) throw conflict("请通过废弃单取消,废弃出库单随申请同步取消"); - // Match dispatch picking and relabel confirmation lock order before locking outbound. - DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null - : dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId()); - RelabelApplication application = "RELABEL_OUTBOUND".equals(observed.getOutboundType()) - ? relabelApplications.lockByOutbound(observed.getCustomerId(), observed.getOutboundNo()) : null; - OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo)); + // 服务端身份与父锁仅使用正式共同端口,不按客户去锁整车头部。 + var dispatch = observed.getDispatchOrderId() == null ? null + : execution.lockForArrangement(new com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor(operatorId, + com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode.PLATFORM,null),observed.getDispatchNo()); + RelabelApplication application = observed.getDispatchOrderId()==null && "RELABEL_OUTBOUND".equals(observed.getOutboundType()) + ? cancellations.lockRelabel(observed) : null; + OutboundOrder outbound = cancellations.lock(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); if ("CANCELLED".equals(outbound.getStatus())) return; if (outbound.getDispatchOrderId() != null) { - if (dispatch == null || !Objects.equals(dispatch.getId(), outbound.getDispatchOrderId()) - || !"PENDING_DEPARTURE".equals(dispatch.getStatus()) || value(dispatch.getPickedPackageCount()) > 0 - || value(dispatch.getShippedPackageCount()) > 0) throw conflict("排车已开始执行,不能直接取消出库"); - fleetBindings.requireUnbound(dispatch.getId()); + if (dispatch == null || !Objects.equals(dispatch.dispatchId(), outbound.getDispatchOrderId()) + || dispatch.status()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE + || dispatch.stage()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING + || dispatch.counts()==null || dispatch.counts().pickedPackageCount()>0 || dispatch.counts().shippedPackageCount()>0) + throw conflict("排车已开始执行,不能直接取消出库"); + // 唯一Fleet资源记录随整车自动创建,不能把本车正常关系当作额外取消阻断。 } if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) { throw conflict("Only an unexecuted outbound order can be cancelled"); } String customerId = outbound.getCustomerId(); - List outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId()); + List outboundItems = cancellations.items(outbound); if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> value(i.getPickedPackageCount()) > 0 || !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) { throw conflict("Outbound order cannot be cancelled after execution has started"); } - List existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + boolean relabelArrangement = "RELABEL_OUTBOUND".equals(outbound.getOutboundType()); + String pendingRelabelFulfillment = null; + List existingDeliveries = cancellations.deliveries(outbound); if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus()))) throw conflict("Outbound order cannot be cancelled while delivery is in progress"); + if (outbound.getDispatchOrderId()!=null && "RELABEL_OUTBOUND".equals(outbound.getOutboundType())) + application=cancellations.lockRelabel(outbound); LocalDateTime now = LocalDateTime.now(); if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { if (application == null || !customerId.equals(application.getCustomerId()) || !outbound.getOutboundNo().equals(application.getOutboundOrderNo()) + || !Objects.equals(outbound.getOverseasWarehouseId(),application.getOverseasWarehouseId()) || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) throw conflict("换标出库关联状态已变化,不能取消"); for (OutboundPalletItem item : outboundItems) { if (item.getWarehousePalletId() != null) continue; for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) { - ReturnInventoryCarton carton = returnCartons.lockMark(mark); + ReturnInventoryCarton carton = cancellations.lockCarton(mark); if (carton == null || !customerId.equals(carton.getCustomerId()) + || !Objects.equals(outbound.getOverseasWarehouseId(),carton.getOverseasWarehouseId()) || !Objects.equals(application.getId(), carton.getRelabelApplicationId()) || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货换标箱预留状态异常,不能取消出库"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); - returnCartons.updateById(carton); + cancellations.save(carton); } } - relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId, - new RelabelRequests.ChangeStatus("CANCELLED", request.reason().trim())); + // 原生箱和普通仓托均只撤销本次安排;整车分支仍须先通过父锁、阶段和零扫码门禁。 + pendingRelabelFulfillment = "TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION"; + if (!cancellations.restoreRelabelArrangement(application,outbound.getOutboundNo(),pendingRelabelFulfillment,now)) + throw conflict("原换标出库关联已变化,未取消本次安排"); } if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) { - ReturnInventoryCarton carton = returnCartons.lockMark(mark); + ReturnInventoryCarton carton = cancellations.lockCarton(mark); if (carton == null || !customerId.equals(carton.getCustomerId()) || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货箱预留状态异常,不能取消出库"); - carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); cancellations.save(carton); + } + } + // 已收齐子单后,先锁齐全部相关来源;费用锁之后不得再发现新的来源锁。 + var cancelTransfers=new LinkedHashMap(); + if("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) + for(var no:outboundItems.stream().map(OutboundPalletItem::getOrderRefs).filter(StringUtils::hasText).distinct().sorted().toList()) { + var transfer=cancellations.lockTransfer(customerId,no);if(transfer!=null)cancelTransfers.put(no,transfer); + } + var cancelSources=new TreeMap(); + for(var selected:cancellations.sources(customerId,outboundNo)) { + var current=cancellations.lockSource(customerId,selected.getId()); + if(current!=null && outboundNo.equals(current.getOutboundOrderNo()))cancelSources.put(current.getId(),current); + } + if (relabelArrangement) { + // 仅清本次出库/派送关联,来源主状态和换标完成事实保持原值,不能退回未换标库存。 + for (var order : cancelSources.values()) { + if (!cancellations.restore(order,outboundNo,order.getCurrentStatus(),"RELABEL:"+pendingRelabelFulfillment,now)) + throw conflict("原换标来源出库关联已变化,请刷新核对"); } } for (DeliveryOrder delivery : existingDeliveries) { @@ -1236,61 +990,58 @@ public class DeliveryTransferService { voidRequests++; } } - recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND", + recordCancellationOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND", "出库取消;" + voidRequests + "笔未履行派送应收已申请撤销,其他费用保留待核对", now); String reason = request.reason().trim(); for (int offset = 0; offset < reason.length(); offset += 480) - recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON", + recordCancellationOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON", "(" + (offset / 480 + 1) + ") " + reason.substring(offset, Math.min(offset + 480, reason.length())), now); delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); - delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); + delivery.setUpdatedAt(now); cancellations.save(delivery); } - OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); - if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } - outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); - for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } + OutboundPdaTask task = cancellations.task(outbound); + if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); } + outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); cancellations.save(outbound); + for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); cancellations.save(item); } if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs) .filter(StringUtils::hasText).distinct().toList()) { - WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo); + WarehouseTransferOrder transfer = cancelTransfers.get(transferNo); if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue; String restored = "FBA".equals(transfer.getDestinationType()) || "PRIVATE".equals(transfer.getDestinationType()) ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; - transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); - LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + transfer.setStatus(restored); transfer.setUpdatedAt(now); cancellations.save(transfer); + LastlegOrder order = cancelSources.get(transfer.getLastlegOrderId()); if (order != null && customerId.equals(order.getCustomerId())) { order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); - tailOrders.updateById(order); + if(!cancellations.restore(order,outboundNo,restored,"WAREHOUSE_TRANSFER:"+restored,now)) + throw conflict("转仓来源关联已变化,请刷新核对"); } } return; } if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList(); - for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId) - .eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) { - var order = tailOrders.lockById(customerId, selected.getId()); + for (LastlegOrder order : cancelSources.values()) { if (order == null || !outboundNo.equals(order.getOutboundOrderNo())) continue; - var previous = histories.selectOne(new LambdaQueryWrapper() - .eq(LastlegOrderStatusHistory::getCustomerId, customerId).eq(LastlegOrderStatusHistory::getOrderId, order.getId()) - .in(LastlegOrderStatusHistory::getEventId, transferEvents) - .orderByAsc(LastlegOrderStatusHistory::getCreatedAt, LastlegOrderStatusHistory::getId).last("LIMIT 1")); + var previous = cancellations.previous(customerId,order.getId(),transferEvents); if (previous == null || !StringUtils.hasText(previous.getFromStatus())) throw conflict("缺少本次转派送前的订单状态记录,请先核对来源"); String from = order.getCurrentStatus(); - if (tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() - .eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId) - .eq(LastlegOrder::getOutboundOrderNo, outboundNo) - .set(LastlegOrder::getCurrentStatus, previous.getFromStatus()) - .set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED") - .set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null) - .set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1) + if (!cancellations.restore(order,outboundNo,previous.getFromStatus(),"OUTBOUND:CANCELLED;DELIVERY:CANCELLED",now)) throw conflict("订单关联已变化,请刷新后重试"); - LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); cancellations.history(history); } } } + /** 取消事实按完整原因分段写入,不借通用Service直调Mapper。 */ + private void recordCancellationOperation(String customerId,Long deliveryId,String operator,String node,String result,LocalDateTime now) { + var operation=new DeliveryOperation();operation.setCustomerId(customerId);operation.setDeliveryOrderId(deliveryId); + operation.setOperatorId(operator);operation.setOperatorName(operator);operation.setOperationNode(node);operation.setResult(result);operation.setCreatedAt(now); + cancellations.operation(operation); + } + public List pdaTasks(String warehouseId, String status) { String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream() @@ -1326,7 +1077,7 @@ public class DeliveryTransferService { OutboundOrder outbound = outboundOrders.findGlobalByNo(code); if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) { - DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo()); + DispatchAggregate dispatch = dispatches.find(outbound.getDispatchNo()); if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(), dispatch.getDispatchNo()); @@ -1336,7 +1087,7 @@ public class DeliveryTransferService { if (task != null) return outboundResolution(outbound, task); } - DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code); + DispatchAggregate dispatch = dispatches.find(code); if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo()); } @@ -1590,47 +1341,6 @@ public class DeliveryTransferService { } catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); } } - 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.setCustomerId(customerId); 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 List requireDeliveries(String customerId, Long outboundId) { - List values = deliveryOrders.lockByOutbound(customerId, outboundId); - if (values.isEmpty()) throw new IllegalStateException("Delivery documents are missing for outbound order"); - return values; - } - private DeliveryResponses.TransferResult result(OutboundOrder o, List deliveries, boolean replayed) { - return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), - o.getPalletCount(), o.getPackageCount(), deliveries.stream().map(d -> new DeliveryResponses.TransferDelivery( - d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), - d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); - } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), @@ -1653,7 +1363,6 @@ public class DeliveryTransferService { private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } - private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } private String deliveryGroupKey(DispatchItem item) { if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) { return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim(); @@ -1663,16 +1372,6 @@ public class DeliveryTransferService { if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); throw conflict("Dispatch pallet is missing its destination"); } - private String resolveLxCodes(String customerId, List items) { - List inboundCodes = items.stream().map(DispatchItem::getInboundCode) - .filter(StringUtils::hasText).map(String::trim).distinct().toList(); - if (inboundCodes.isEmpty()) return ""; - return tailOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) - .in(LastlegOrder::getInboundCode, inboundCodes)) - .stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText) - .map(String::trim).distinct().collect(java.util.stream.Collectors.joining(",")); - } private void updateOutboundPickingStatus(String customerId, OutboundOrder outbound) { List items = outboundPallets.findByOutbound(customerId, outbound.getId()); String nextStatus = !items.isEmpty() && items.stream().allMatch(item -> "PICKED".equals(item.getStatus())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java deleted file mode 100644 index b4b94c8f8..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.baiyue.lastleg.delivery.service; - -import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; -import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import org.springframework.stereotype.Service; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; -import java.util.*; - -/** 外接绑定和约定运费共同消费同一份逐来源货物。 */ -@Service -public class DispatchCargoSourceQuery { - private final DispatchCargoSourceRepository repository; - /** 装配当前库存与本次拣货的真实来源入口。 */ - public DispatchCargoSourceQuery(DispatchCargoSourceRepository repository) { this.repository = repository; } - - /** 新增普通派送应收只绑定真实未约定来源;混合多票时保留派送归属。 */ - public Set ordinaryOrderIds(DispatchOrder dispatch, Set externalInboundCodes) { - return repository.sources(dispatch.getCustomerId(), dispatch.getId()).stream() - .filter(row -> !externalInboundCodes.contains(row.inboundCode())) - .map(row -> row.orderId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet()); - } - - /** 每条普通排车明细核平实际组成;箱级换标和退货保留其专用来源快照。 */ - public List sources(DispatchOrder dispatch, List items) { - if (items.isEmpty()) return List.of(); - var rows = repository.sources(dispatch.getCustomerId(), dispatch.getId()); - var result = new ArrayList(); - for (var item : items) { - String destination = FleetDispatchGateway.destinationKey(item.getDestinationType(), - item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()); - // 实收重派按不可变实收记录选货,箱级换标按箱来源选货;都不是普通整托候选。 - if (item.getReturnReceiptId() != null || Set.of("RELABEL", "RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(), ""))) { - result.add(new FleetDispatchGateway.SourceCargo(item.getInboundCode(), - item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), - item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), destination)); - continue; - } - var selected = rows.stream().filter(row -> Objects.equals(row.dispatchItemId(), item.getId())).toList(); - if (selected.isEmpty() || item.getPackageCount() == null || selected.stream().mapToLong(row -> row.packages()).sum() != item.getPackageCount()) - throw conflict("实际货物组成与排车箱数不一致,请核对收货、托盘及本次拣货来源"); - for (var row : selected) { - if (row.inboundCode() == null || row.inboundCode().isBlank() || row.packages() <= 0 - || !Objects.equals(row.warehousePalletId(), item.getWarehousePalletId())) - throw conflict("货物缺少真实入仓来源或实物托盘,不能识别为普通货跳过外接核对"); - result.add(new FleetDispatchGateway.SourceCargo(row.inboundCode(), row.warehousePalletId().toString(), - row.palletNo(), Math.toIntExact(row.packages()), destination)); - } - } - return List.copyOf(result); - } - - /** 来源不足直接阻断,不回退展示字段。 */ - private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java new file mode 100644 index 000000000..9d598d203 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java @@ -0,0 +1,179 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.client.FinanceFeeCoverageClient; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups.Group; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; + +/** 逐客户逐目的地确认报价与覆盖,费用只归属于该组真实派送。 */ +@Service +public class DispatchDeliveryFees { + private final FleetBookingFreight external; + private final FinanceFeeCoverageClient finance; + private final OverseasFeeSourceService fees; + private final DeliveryCargoGroups groups; + private final DispatchDeliveryRepository repository; + private final String internalToken; + private static final Set SERVICES=Set.of("DELIVERY_FEE","LOADING_FEE","APPOINTMENT_FEE"); + + /** 复用现有报价、费用发布和Finance只读契约,不引入另一套账务。 */ + public DispatchDeliveryFees(FleetBookingFreight external,FinanceFeeCoverageClient finance, + OverseasFeeSourceService fees,DeliveryCargoGroups groups,DispatchDeliveryRepository repository, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.external=external;this.finance=finance;this.fees=fees;this.groups=groups; + this.repository=repository;this.internalToken=internalToken; + } + + /** 一客户一次按全部真实来源校验外接实收,避免将一笔完整报价按目的地重复核价。 */ + public Map external(Context context) { + var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(CargoLine::customerId,TreeMap::new,Collectors.toList())); + var result=new LinkedHashMap(); + for (var entry:byCustomer.entrySet()) { + var cargo=entry.getValue().stream().map(c->new FleetDispatchGateway.SourceCargo(c.inboundCode(), + c.warehousePalletId()==null?null:c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key())).toList(); + result.put(entry.getKey(),external.preview(context.dispatchId(),context.dispatchNo(),entry.getKey(),cargo)); + } + return result; + } + + /** 原费候选按客户批量只读,响应越界或不可用直接失败,不能认作已覆盖。 */ + public List original(String customerId,List cargo) { + var ids=new TreeSet(); + for (var row:cargo) if ("FIRSTLEG".equals(row.sourceRegion())) { + try { long id=Long.parseLong(row.sourceInboundId());if(id<=0)throw new NumberFormatException();ids.add(id); } + catch (RuntimeException invalid) { throw conflict("原头程货物缺少真实来源关联"); } + } + if (ids.isEmpty()) return List.of(); + try { + var response=finance.sources(internalToken,new FinanceFeeCoverageClient.Query(Long.valueOf(customerId),List.copyOf(ids))); + if (response==null || !response.successful() || response.data()==null) throw unavailable(); + for (var fee:response.data()) if (fee==null || fee.financeFeeId()==null || fee.financeFeeId()<=0 + || fee.sourceFeeId()==null || !Objects.equals(fee.customerId(),Long.valueOf(customerId)) + || !ids.contains(fee.sourceInboundId()) || fee.sourceVersion()<1 || blank(fee.sourceHash()) + || fee.amount()==null || fee.amount().signum()<0 || !"CNY".equals(fee.currency()) + || !Set.of("INBOUND_FEE","CONTAINER_FEE").contains(fee.sourceType()) + || !Set.of("AVAILABLE","SETTLED").contains(fee.status())) throw unavailable(); + return response.data(); + } catch (RuntimeException unavailable) { throw unavailable(); } + } + + /** 平台按真实组服务范围确认原费,返回不可变证据供客户出库保存。 */ + public DeliveryFeeCoverageModels.Evidence coverage(Group group,ArrangeGroup input,String operatorId, + FleetBookingFreight.Preview freight) { + var selected=input.feeCoverage()==null?List.of():input.feeCoverage(); + if (selected.isEmpty()) return null; + if (!Objects.equals(group.cargoHash(),input.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化"); + var candidates=original(group.customerId(),group.cargo()); + var references=new ArrayList(); var scopes=new HashSet(); + for (var selection:selected) { + if (selection==null || selection.services()==null || selection.services().isEmpty()) throw conflict("请选择明确包含的服务"); + var fee=candidates.stream().filter(f->Objects.equals(f.financeFeeId(),selection.financeFeeId())).findFirst() + .orElseThrow(()->conflict("原费用不属于本客户本次货物或已失效")); + if (fee.sourceVersion()!=selection.sourceVersion() || !Objects.equals(fee.sourceHash(),selection.sourceHash())) + throw conflict("原费用版本已变化,请重新核对"); + var services=new TreeSet<>(selection.services()); + if (services.size()!=selection.services().size() || !SERVICES.containsAll(services)) throw conflict("原费用服务范围重复或无效"); + for (var service:services) if (!scopes.add(fee.sourceInboundId()+":"+service)) throw conflict("同一来源服务不得重复引用原费"); + references.add(new DeliveryFeeCoverageModels.Reference(fee,List.copyOf(services))); + } + var cargo=group.cargo().stream().map(c->new DeliveryFeeCoverageModels.Cargo(c.dispatchItemId(),c.warehousePalletId(), + c.palletNo(),c.orderId(),c.sourceRegion(),c.sourceInboundId(),c.inboundCode(),c.packages(),c.inboundCode())).toList(); + return new DeliveryFeeCoverageModels.Evidence(group.cargoHash(),cargo,List.copyOf(references),operatorId,Instant.now()); + } + + /** 按每条真实来源判断某服务是否已含,外接约定只覆盖派送服务。 */ + private boolean covered(CargoLine cargo,String service,DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight) { + if ("DELIVERY_FEE".equals(service) && freight.quotes().stream().anyMatch(q->Objects.equals(q.inboundCode(),cargo.inboundCode()))) return true; + return evidence!=null && "FIRSTLEG".equals(cargo.sourceRegion()) && evidence.references().stream().anyMatch(r-> + Objects.equals(r.source().sourceInboundId().toString(),cargo.sourceInboundId()) && r.services().contains(service)); + } + + /** 所有收费资格在创建子单前核验,新增应收只能针对未覆盖来源。 */ + public void validate(Group group,ArrangeGroup input,DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight) { + if (input.plannedDeliveryAt()==null || blank(input.deliveryMethod()) || blank(input.pickupMethod()) || blank(input.loadingType())) + throw conflict("请补齐每个客户目的地的派送信息"); + if (!Objects.equals(input.groupCargoHash(),group.cargoHash())) throw conflict("派送货物分组已变化"); + if (!Objects.equals(input.externalFreightHash(),freight.snapshotHash())) throw conflict("外接实收或约定运费已变化"); + var seen=new HashSet(); + for (var fee:input.fees()==null?List.of():input.fees()) { + if (fee==null || blank(fee.feeType()) || !SERVICES.contains(fee.feeType()) || blank(fee.direction()) || !Set.of("RECEIVABLE","PAYABLE").contains(fee.direction()) + || blank(fee.currency()) || !Set.of("CNY","USD").contains(fee.currency()) || blank(fee.feeName()) || fee.amount()==null || fee.amount().signum()<=0) + throw conflict("派送费用类型、方向、币种或金额不合法"); + if (!seen.add(fee.direction()+":"+fee.feeType())) throw conflict("同组同方向同费用不得重复登记"); + if ("RECEIVABLE".equals(fee.direction()) && group.cargo().stream().allMatch(c->covered(c,fee.feeType(),evidence,freight))) + throw conflict("本组该服务已全部覆盖,不得重复登记应收"); + } + var required=new HashSet(); required.add("DELIVERY_FEE"); + if (Set.of("FLOOR","MIXED").contains(input.loadingType())) required.add("LOADING_FEE"); + if ("BUY_APPOINTMENT".equals(input.appointmentAccount())) { + if (blank(input.sellerName())) throw conflict("买约须填写卖家名称");required.add("APPOINTMENT_FEE"); + } + for (var code:required) if (group.cargo().stream().anyMatch(c->!covered(c,code,evidence,freight)) && !seen.contains("RECEIVABLE:"+code)) + throw conflict("本组尚有未覆盖的服务费用,请核对" ); + } + + /** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */ + public void requireOriginalVoided(String customerId,FleetBookingFreight.Preview freight) { + fees.requireNoActiveDeliveryReceivables(customerId,freight.priorInboundCodes()); + } + + /** 费用与子单同事务保存,只向真实客户目的地发布一次正数来源。 */ + public void publish(Context context,Group group,ArrangeGroup input,DeliveryOrder delivery, + DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight,String operatorId,String arrangementKey) { + var now=LocalDateTime.now(); + for (var quote:freight.quotes()) if (Objects.equals(quote.destinationKey(),group.destination().key()) + && group.cargo().stream().anyMatch(c->Objects.equals(c.inboundCode(),quote.inboundCode()))) { + var operation=new DeliveryOperation();operation.setCustomerId(group.customerId());operation.setDeliveryOrderId(delivery.getId()); + operation.setOperatorId(operatorId);operation.setOperationNode("CONFIRM_EXTERNAL_FREIGHT"); + operation.setResult("外接预订 "+quote.bookingNo()+";实收确认版本 "+quote.recordVersion()+";约定运费 "+quote.currency()+" "+quote.amount().toPlainString()); + operation.setCreatedAt(now);repository.operation(operation); + if (quote.amount().signum()==0) continue; + fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),quote.bookingNo(),"FREIGHT")), + save(group,delivery,null,"DELIVERY_FEE","外接约定运费", "RECEIVABLE",quote.currency(),quote.amount(), + quote.quantity(),quote.quantityUnit(),quote.unitPrice(),null,quote.inboundCode())); + } + for (var fee:input.fees()==null?List.of():input.fees()) { + var charged=group.cargo().stream().filter(c->!"RECEIVABLE".equals(fee.direction())||!covered(c,fee.feeType(),evidence,freight)).toList(); + var orders=charged.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList(); + var inbound=charged.stream().map(CargoLine::inboundCode).filter(Objects::nonNull).distinct().toList(); + fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),fee.direction(),fee.feeType())), + save(group,delivery,orders.size()==1?orders.get(0):null,fee.feeType(),fee.feeName(),fee.direction(),fee.currency(), + fee.amount(),fee.chargeableQuantity(),fee.quantityUnit(),fee.unitPrice(),fee,inbound.size()==1?inbound.get(0):null)); + } + } + + /** 多来源不借第一票挂账,供应商应付与客户覆盖独立。 */ + private OverseasFeeSourceDtos.SaveRequest save(Group group,DeliveryOrder delivery,Long orderId,String code,String name, + String direction,String currency,BigDecimal amount,BigDecimal quantity,String unit,BigDecimal unitPrice,FeeItem input,String inboundCode) { + return new OverseasFeeSourceDtos.SaveRequest(group.customerId(),group.customerName(),"OVERSEAS_DELIVERY_FEE","DELIVERY_ORDER", + delivery.getId(),delivery.getDeliveryNo(),orderId,null,inboundCode,null,null,null,Long.valueOf(delivery.getOverseasWarehouseId()), + code,name,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(), + input==null?null:input.settlementMode(),input==null||input.paidAt()==null?null:LocalDateTime.ofInstant(input.paidAt(),ZoneId.systemDefault()), + input==null?null:input.paymentMethod(),input==null?null:input.paymentReferenceNo(),input==null?null:input.paymentVoucherFileIds(), + LocalDate.now(),currency,amount,quantity,unit,unitPrice,input==null?null:input.periodStart(),input==null?null:input.periodEnd()); + } + + /** 只有真实业务标识可用于费用归属。 */ + private static boolean blank(String value) { return value==null||value.isBlank(); } + /** 财务只读失败不降级为已覆盖或零费。 */ + private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"原费用来源暂不可用,请稍后核对"); } + /** 校验失败由外层事务回滚所有客户单据。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java new file mode 100644 index 000000000..46f2689c0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java @@ -0,0 +1,354 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups.Group; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */ +@Service +public class DispatchDeliveryService implements DispatchDeliveryPort { + private final DispatchExecutionPort execution; + private final DispatchDeliveryRepository repository; + private final DeliveryCargoGroups grouping; + private final DispatchDeliveryFees fees; + private final NormalDeliveryCompletionPolicy completion; + private final LastlegOrderNoGenerator numbers; + private final ObjectMapper json; + + /** 消费主流程实际公共骨架,禁止注入对方Controller或具体Service。 */ + public DispatchDeliveryService(@org.springframework.context.annotation.Lazy DispatchExecutionPort execution,DispatchDeliveryRepository repository, + DeliveryCargoGroups grouping,DispatchDeliveryFees fees,NormalDeliveryCompletionPolicy completion, + LastlegOrderNoGenerator numbers,ObjectMapper json) { + this.execution=execution;this.repository=repository;this.grouping=grouping;this.fees=fees; + this.completion=completion;this.numbers=numbers;this.json=json; + } + + /** 组合GET的入口上下文和本地派送数据加入同一个只读快照。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ArrangementPreview preview(Actor actor,String dispatchNo) { + return preview(execution.preview(actor,dispatchNo)); + } + + /** 外部原费只读带版本证据,不能声称参与Lastleg数据库同刻快照。 */ + @Override + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ArrangementPreview preview(Context context) { + var saved=repository.arrangement(context.dispatchId(),false); + var result=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); + var gate=arrangementGate(context); + if (context.cargo()==null || context.cargo().isEmpty() || !context.pickingReconciled()) + return new ArrangementPreview(context.dispatchNo(),context.recordVersion(),context.status(),context.stage(), + context.cargoHash(),context.loadingCompletionHash(),gate,List.of(),result); + var groups=grouping.groups(context); var external=fees.external(context); + var original=new HashMap>(); + for (var customer:groups.stream().map(Group::customerId).distinct().toList()) + original.put(customer,fees.original(customer,context.cargo().stream().filter(c->customer.equals(c.customerId())).toList())); + var previews=new ArrayList(); + for (var group:groups) { + var freight=external.get(group.customerId()); + var quotes=freight.quotes().stream().filter(q->Objects.equals(q.destinationKey(),group.destination().key()) + && group.cargo().stream().anyMatch(c->Objects.equals(c.inboundCode(),q.inboundCode()))) + .map(q->new ExternalQuote(group.customerId(),group.key(),q.bookingNo(),q.inboundCode(),q.currency(),q.amount(),freight.snapshotHash())).toList(); + var candidates=original.get(group.customerId()).stream().filter(f->group.cargo().stream().anyMatch(c-> + Objects.equals(c.sourceInboundId(),f.sourceInboundId().toString()))) + .map(f->new FeeCandidate(f.financeFeeId(),group.customerId(),f.currency(),f.amount(),f.sourceVersion(),f.sourceHash(), + f.status(),List.of("DELIVERY_FEE","LOADING_FEE","APPOINTMENT_FEE"),group.cargo().stream() + .filter(c->Objects.equals(c.sourceInboundId(),f.sourceInboundId().toString())).map(CargoLine::sourceKey).distinct().toList())).toList(); + var required=group.cargo().stream().anyMatch(c->freight.quotes().stream().noneMatch(q->Objects.equals(q.inboundCode(),c.inboundCode()))) + ?List.of("DELIVERY_FEE"):List.of(); + previews.add(new PreviewGroup(group.key(),group.customerId(),group.customerName(),group.destination(),group.cargoHash(),group.cargo(), + freight.snapshotHash(),quotes,group.cargoHash(),candidates,required,List.of())); + } + return new ArrangementPreview(context.dispatchNo(),context.recordVersion(),context.status(),context.stage(),context.cargoHash(), + context.loadingCompletionHash(),gate,List.copyOf(previews),result); + } + + /** 先共同父锁和严格命令恢复,再按客户与目的地原子创建全车单据。 */ + @Override + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult arrange(Actor actor,String dispatchNo,ArrangeDelivery request) { + if (actor==null || actor.mode()!=ActorMode.PLATFORM) throw new ResponseStatusException(HttpStatus.FORBIDDEN,"整车派送由平台安排"); + if (request==null || request.recordVersion()<0 || blank(request.idempotencyKey()) || request.idempotencyKey().length()>255 + || blank(request.cargoHash()) || blank(request.loadingCompletionHash()) || request.groups()==null || request.groups().isEmpty() + || request.groups().size()>200) throw conflict("安排派送请求不完整"); + var canonical=canonical(request); + var context=execution.lockForArrangement(actor,dispatchNo); + var identity=new CommandIdentity(Action.ARRANGE_DELIVERY,request.idempotencyKey(),request.recordVersion(), + grouping.hash(List.of(actor,Action.ARRANGE_DELIVERY,context.dispatchNo(),canonical))); + var replay=execution.replay(context,actor,identity); + if (replay.isPresent()) return replay.get(); + String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),canonical.cargoHash(),canonical.loadingCompletionHash(),canonical.groups())); + var saved=repository.arrangement(context.dispatchId(),true); + if (saved!=null) { + if (context.recordVersion()!=request.recordVersion() || !Objects.equals(saved.getBusinessHash(),businessHash)) + throw conflict("本车已安排派送,当前版本或业务内容不同"); + var current=departureFacts(context,true); + // 发车后的原安排也可作为既有业务事实读取,但取消/缺项绝不能补建为成功。 + if (context.status()!=Status.ACTIVE || current.outbounds().isEmpty() + || current.outbounds().stream().anyMatch(o->"CANCELLED".equals(o.status())) + || current.deliveries().stream().anyMatch(d->"CANCELLED".equals(d.status())) + || !completeDocuments(context,saved,repository.outbounds(context.dispatchId(),false),repository.deliveries(context.dispatchId(),false))) + throw conflict("原安排单据不完整或已取消,请核对原单据"); + return execution.recordArrangement(context,actor,identity,fact(saved),true); + } + execution.requireFreshArrangement(context,request.recordVersion()); + if (!Objects.equals(context.cargoHash(),request.cargoHash()) || !Objects.equals(context.loadingCompletionHash(),request.loadingCompletionHash())) + throw conflict("装车或实际货物已经变化,请重新核对"); + var groups=grouping.groups(context); var inputs=new HashMap(); + for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复"); + if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地"); + // 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。 + var existingOutbounds=repository.outbounds(context.dispatchId(),true); + var existingDeliveries=repository.deliveries(context.dispatchId(),true); + if (!existingOutbounds.isEmpty() || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对"); + var external=fees.external(context);var coverages=new HashMap(); + for (var group:groups) { + var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId())); + fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage); + } + var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap(); + for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { + var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); + var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer); + order.setOutboundNo(numbers.next("OUTBOUND",context.warehouseId(),order.getId())); + order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); + order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes(""); + order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum())); + order.setPalletCount((int)cargo.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count()); + order.setGrossWeightKg(metric(cargo,CargoLine::grossWeightKg));order.setVolumeCbm(metric(cargo,CargoLine::volumeCbm)); + order.setIdempotencyKey(grouping.hash(List.of(key,customer,"OUTBOUND")));order.setRequestHash(businessHash); + order.setCreatedBy(actor.operatorId());order.setCreatedAt(local(now));order.setUpdatedAt(local(now)); + var covered=groups.stream().filter(g->g.customerId().equals(customer)).map(g->coverages.get(g.key())).filter(Objects::nonNull).toList(); + if (!covered.isEmpty()) order.setFeeCoverageJson(write(mergeCoverage(covered,actor.operatorId()))); + repository.create(order);outbounds.put(customer,order); + } + var deliveries=new LinkedHashMap(); + for (var group:groups) { + var input=inputs.get(group.key());var outbound=outbounds.get(group.customerId()); + var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now); + repository.create(delivery);deliveries.put(group.key(),delivery); + for (var row:group.cargo()) { + var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId()); + item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId()); + item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId()); + item.setWarehousePalletId(row.warehousePalletId());item.setPalletNo(row.palletNo());item.setInboundCode(row.inboundCode()); + item.setDestinationWarehouseId(group.destination().warehouseId());item.setDestinationWarehouseCode(group.destination().warehouseCode()); + item.setDestinationWarehouseName(group.destination().warehouseName());item.setPackageCount(Math.toIntExact(row.packages())); + item.setPickedPackageCount(Math.toIntExact(row.packages()));item.setStatus("PICKED"); + item.setGrossWeightKg(row.grossWeightKg());item.setVolumeCbm(row.volumeCbm());item.setCartonMarks(row.cartonMark()); + item.setOrderRefs(row.orderNo());item.setCreatedAt(local(now));repository.create(item); + } + } + // 所有出库/派送均已插入且持有写锁,此后才申请本次全部来源锁。 + bindSources(context,groups,outbounds,deliveries,now); + for (var customer:external.keySet()) fees.requireOriginalVoided(customer,external.get(customer)); + for (var group:groups) fees.publish(context,group,inputs.get(group.key()),deliveries.get(group.key()),coverages.get(group.key()), + external.get(group.customerId()),actor.operatorId(),key); + var result=new ArrangementResult(context.dispatchNo(),context.recordVersion()+1,key,context.cargoHash(),now, + outboundRefs(outbounds.values(),groups),deliveryRefs(deliveries.values(),groups)); + var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId()); + arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash); + arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash()); + arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId()); + arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement); + return execution.recordArrangement(context,actor,identity,fact(arrangement),false); + } + + /** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public DepartureFacts departureFacts(Context context,boolean lock) { + var arrangement=repository.arrangement(context.dispatchId(),lock); + var outbounds=repository.outbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock); + var groups=context.cargo()==null||context.cargo().isEmpty()?List.of():grouping.groups(context); + String reason=arrangement==null?"请先安排本车全部客户派送": + !Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash()) + ?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排": + outbounds.stream().anyMatch(o->!"WAIT_OUTBOUND".equals(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus()) + || d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null; + return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(), + arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups), + context.cargo(),new Gate(reason==null,reason==null?null:"DELIVERY_NOT_READY",reason)); + } + + /** 只更新已锁单据,库存扣减及订单归属转换不在此重复执行。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) { + if (departedAt==null) throw conflict("缺少实际发车时间"); + var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason()); + var outbounds=repository.outbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false); + for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); } + for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt)); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); } + var groups=grouping.groups(context); + return new DepartureResult(outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),outbounds.stream().mapToLong(o->o.getPackageCount()).sum()); + } + + /** 签收汇总仅普通读取全部真实子单,不反向锁来源或再次发布费用。 */ + @Override + @Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED) + public NormalDeliveryFacts normalDeliveryFacts(Context context) { + var saved=repository.arrangement(context.dispatchId(),false); + var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); + var outbounds=repository.outbounds(context.dispatchId(),false); + var actual=new ArrayList(); + for (var delivery:repository.deliveries(context.dispatchId(),false)) { + var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId()) + && Objects.equals(o.getCustomerId(),delivery.getCustomerId()) && Objects.equals(o.getOutboundNo(),delivery.getOutboundNo())).findFirst().orElse(null); + var destination=destination(delivery);String groupKey=grouping.groupKey(delivery.getCustomerId(),destination); + // 使用安排时原组身份,避免FBA领域键与单据CODE键的表示差异。 + if (expected!=null) groupKey=expected.deliveries().stream().filter(d->d.deliveryNo().equals(delivery.getDeliveryNo())) + .map(DeliveryReference::groupKey).findFirst().orElse(groupKey); + long returned=repository.returns(delivery).stream().mapToLong(DeliveryReturnReceiptFact::packages).sum(); + actual.add(new NormalDeliveryFact(delivery.getCustomerId(),groupKey,delivery.getOutboundNo(),delivery.getDeliveryNo(), + Objects.requireNonNullElse(delivery.getVersion(),0L),Objects.requireNonNullElse(delivery.getPackageCount(),0), + Objects.requireNonNullElse(delivery.getSignedPackages(),0),returned,repository.activeReturns(delivery),delivery.getStatus(), + DeliveryExecutionGuard.matches(delivery,outbound),delivery.getSignedAt()==null?null:instant(delivery.getSignedAt()))); + } + var result=completion.evaluate(context,expected,actual,Instant.now()); + if (saved!=null && !completeDocuments(context,saved,outbounds,repository.deliveries(context.dispatchId(),false))) { + var reasons=new ArrayList<>(result.blockReasons());reasons.add("本车出库及派送集合与原安排不一致"); + return new NormalDeliveryFacts(result.dispatchNo(),result.factsHash(),false,null,result.expectedDeliveryNos(),result.deliveries(),List.copyOf(reasons)); + } + return result; + } + + /** 集合、客户、父引用和每组原始箱数全部相等,不仅比较全车总数。 */ + private boolean completeDocuments(Context context,DispatchDeliveryArrangement arrangement,List outbounds,List deliveries) { + var expected=read(arrangement.getResultJson(),ArrangementResult.class); + if (outbounds.size()!=expected.outbounds().size() || deliveries.size()!=expected.deliveries().size()) return false; + for (var ref:expected.outbounds()) if (outbounds.stream().filter(o->Objects.equals(o.getCustomerId(),ref.customerId()) + && Objects.equals(o.getOutboundNo(),ref.outboundNo()) && Objects.equals(o.getDispatchOrderId(),context.dispatchId()) + && Objects.equals(o.getOverseasWarehouseId(),context.warehouseId()) && Objects.equals((long)Objects.requireNonNullElse(o.getPackageCount(),0),ref.packageCount())).count()!=1) return false; + for (var ref:expected.deliveries()) if (deliveries.stream().filter(d->Objects.equals(d.getCustomerId(),ref.customerId()) + && Objects.equals(d.getDeliveryNo(),ref.deliveryNo()) && Objects.equals(d.getOutboundNo(),ref.outboundNo()) + && Objects.equals(d.getDispatchOrderId(),context.dispatchId()) && Objects.equals(d.getOverseasWarehouseId(),context.warehouseId()) + && Objects.equals(d.getDeliveryGroupKey(),grouping.documentGroupKey(ref.destination())) + && Objects.equals((long)Objects.requireNonNullElse(d.getPackageCount(),0),ref.packageCount()) + && outbounds.stream().anyMatch(o->Objects.equals(o.getId(),d.getOutboundOrderId()) && Objects.equals(o.getCustomerId(),d.getCustomerId()))).count()!=1) return false; + return expected.cargoHash().equals(context.cargoHash()); + } + + /** 按原实物来源数字ID一次锁齐全部来源,随后绑定状态和历史。 */ + private void bindSources(Context context,List groups,Map outbounds,Map deliveries,Instant now) { + var sources=new TreeMap>(); + for (var row:context.cargo()) if(row.orderId()!=null)sources.computeIfAbsent(row.orderId(),ignored->new ArrayList<>()).add(row); + var locked=new TreeMap(); + for (var entry:sources.entrySet()) { + var customer=entry.getValue().get(0).customerId(); + if(entry.getValue().stream().anyMatch(c->!customer.equals(c.customerId())))throw conflict("同一来源订单出现不同客户"); + var source=repository.lockSource(customer,entry.getKey()); + if(source==null||!Objects.equals(source.getCustomerId(),customer)||Boolean.TRUE.equals(source.getDetained()))throw conflict("派送来源订单不存在、暂扣或归属错误"); + locked.put(entry.getKey(),source); + } + for (var entry:sources.entrySet()) { + var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId()); + var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey()))) + .map(g->deliveries.get(g.key())).toList(); + repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now)); + for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId()); + history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo()); + history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);} + } + } + + /** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */ + private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) { + var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName()); + d.setDeliveryNo(numbers.next("DELIVERY",context.warehouseId(),d.getId()));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo()); + d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName()); + var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type()); + d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName()); + d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone()); + d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt())); + d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType()); + d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark()); + if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate()); + if(context.resources().driver()!=null){d.setDriverName(context.resources().driver().name());d.setDriverPhone(context.resources().driver().phone());}} + d.setPackageCount(Math.toIntExact(group.packages()));d.setPalletCount(Math.toIntExact(group.palletCount())); + d.setTotalWeightKg(metric(group.cargo(),CargoLine::grossWeightKg));d.setTotalVolumeCbm(metric(group.cargo(),CargoLine::volumeCbm)); + d.setIdempotencyKey(grouping.hash(List.of(key,group.key())));d.setCreatedBy(operator);d.setCreatedAt(local(now));d.setUpdatedAt(local(now));return d; + } + + /** 输出全部真实客户出库,不能留单个outboundNo别名。 */ + private List outboundRefs(Collection rows,List groups) { + return rows.stream().map(o->new OutboundReference(o.getCustomerId(),groups.stream().filter(g->g.customerId().equals(o.getCustomerId())) + .map(Group::customerName).filter(Objects::nonNull).findFirst().orElse(null),o.getOutboundNo(),o.getStatus(),Objects.requireNonNullElse(o.getPackageCount(),0),Objects.requireNonNullElse(o.getPalletCount(),0))).toList(); + } + /** 子单组身份由客户和目的地共同确定。 */ + private List deliveryRefs(Collection rows,List groups) { + return rows.stream().map(d->{var group=groups.stream().filter(g->g.customerId().equals(d.getCustomerId()) + && grouping.documentGroupKey(g.destination()).equals(d.getDeliveryGroupKey())).findFirst().orElse(null); + var dest=group==null?destination(d):group.destination();return new DeliveryReference(group==null?grouping.groupKey(d.getCustomerId(),dest):group.key(), + d.getCustomerId(),d.getCustomerName(),d.getOutboundNo(),d.getDeliveryNo(),dest,d.getStatus(),Objects.requireNonNullElse(d.getPackageCount(),0),Objects.requireNonNullElse(d.getPalletCount(),0));}).toList(); + } + /** 读取客户资料时只保留本客户来源与证据,原费用本身不复制为新来源。 */ + private DeliveryFeeCoverageModels.Evidence mergeCoverage(List rows,String operator) { + return new DeliveryFeeCoverageModels.Evidence(grouping.hash(rows.stream().map(DeliveryFeeCoverageModels.Evidence::cargoHash).toList()), + rows.stream().flatMap(e->e.cargo().stream()).toList(),rows.stream().flatMap(e->e.references().stream()).distinct().toList(),operator,Instant.now()); + } + /** 仅依据主流程阶段和真实核平显示资格,写入还需共同端口复验。 */ + private Gate arrangementGate(Context c) { + boolean allowed=c.status()==Status.ACTIVE&&c.stage()==Stage.AFTER_LOADING&&c.pickingReconciled()&&c.loadingCompletedAt()!=null; + return new Gate(allowed,allowed?null:"ARRANGEMENT_NOT_READY",allowed?null:"请先完成真实拣货核平及装车确认"); + } + /** 严格命令和业务摘要共用规范化集合,但原命令不移除版本和主体。 */ + private ArrangeDelivery canonical(ArrangeDelivery request) { + var inputs=new ArrayList(); + for(var g:request.groups()) { if(g==null||blank(g.groupKey()))throw conflict("派送分组身份不能为空"); + if(g.fees()!=null && (g.fees().size()>20||g.fees().stream().anyMatch(Objects::isNull)) + || g.feeCoverage()!=null && (g.feeCoverage().size()>2000||g.feeCoverage().stream().anyMatch(s->s==null||s.services()==null||s.services().stream().anyMatch(Objects::isNull)))) + throw conflict("派送费用或覆盖范围不合法"); + var selections=g.feeCoverage()==null?List.of():g.feeCoverage().stream() + .map(s->new CoverageSelection(s.financeFeeId(),s.sourceVersion(),s.sourceHash(),s.services().stream().sorted().toList())) + .sorted(Comparator.comparing(CoverageSelection::financeFeeId,Comparator.nullsFirst(Long::compareTo))).toList(); + var feeInputs=g.fees()==null?List.of():g.fees().stream().sorted(Comparator.comparing(FeeItem::direction,Comparator.nullsFirst(String::compareTo)) + .thenComparing(FeeItem::feeType,Comparator.nullsFirst(String::compareTo))).toList(); + inputs.add(new ArrangeGroup(g.groupKey(),g.groupCargoHash(),g.plannedDeliveryAt(),g.deliveryMethod(),g.waybillNo(),g.carrierName(),g.pickupMethod(), + g.loadingType(),g.vehicleType(),g.vehicleSize(),g.appointmentAccount(),g.sellerName(),g.remark(),g.externalFreightHash(),g.feeCoverageCargoHash(),selections,feeInputs));} + inputs.sort(Comparator.comparing(ArrangeGroup::groupKey)); + return new ArrangeDelivery(request.recordVersion(),request.idempotencyKey(),request.cargoHash(),request.loadingCompletionHash(),List.copyOf(inputs)); + } + /** 将持久安排还原为共同端口的完整不可变事实。 */ + private ArrangementFact fact(DispatchDeliveryArrangement saved) { return new ArrangementFact(saved.getArrangementKey(),saved.getBusinessHash(),saved.getCargoHash(), + saved.getLoadingCompletionHash(),read(saved.getResultJson(),ArrangementResult.class)); } + /** 始终使用原目的地业务数据,不用内部ID回退。 */ + private Destination destination(DeliveryOrder d) { return new Destination(d.getDeliveryGroupKey(),d.getDestinationType(),d.getDestinationWarehouseId(), + d.getDestinationWarehouseCode(),d.getDestinationWarehouseName(),d.getDeliveryAddress(),d.getPostalCode(),d.getContactName(),d.getContactPhone()); } + /** 未知物理量保持未知,不能补零或拿计费重量替代。 */ + private BigDecimal metric(List cargo,Function value) { return cargo.stream().anyMatch(c->value.apply(c)==null)?null: + cargo.stream().map(value).reduce(BigDecimal.ZERO,BigDecimal::add); } + /** 序列化不可变安排,不接受无法保存的半成品。 */ + private String write(Object value) { try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException("派送事实无法保存",e);} } + /** 损坏的持久事实阻断,不能回退创建另一套单据。 */ + private T read(String value,Class type) { try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("派送事实无法读取",e);} } + /** 按既有服务端时区写本地单据时间。 */ + private static LocalDateTime local(Instant value) { return LocalDateTime.ofInstant(value,ZoneId.systemDefault()); } + /** 按既有服务端时区读取实际事件时间。 */ + private static Instant instant(LocalDateTime value) { return value.atZone(ZoneId.systemDefault()).toInstant(); } + /** 必要业务标识不允许空白。 */ + private static boolean blank(String value) { return value==null||value.isBlank(); } + /** 任一客户失败由外层事务回滚全部单据和费用。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java new file mode 100644 index 000000000..8f27477dd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; + +/** 只从认证主体构造平台操作身份,不接收请求中的客户/操作者参数。 */ +public final class DispatchActorFactory { + /** 工具类不可实例化。 */ + private DispatchActorFactory() { } + + /** 验证平台技术范围,客户账号不能读取或安排整车。 */ + public static Actor platform(AuthenticatedUser user) { + if(user==null || user.tenantId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"请先登录"); + if(!"PLATFORM".equals(user.appCode()) || user.tenantId()!=0L) throw new BizException(ResultCode.FORBIDDEN,"仅平台可访问整车作业"); + String operator=user.userId()==null?user.username():user.userId().toString(); + if(operator==null || operator.isBlank()) throw new BizException(ResultCode.UNAUTHORIZED,"操作身份无效"); + return new Actor(operator,ActorMode.PLATFORM,null); + } + /** PDA仓和操作者仅由既有仓作业身份校验器取出。 */ + public static Actor pda(AuthenticatedUser user) { + var identity=com.baiyue.lastleg.pda.security.PdaWarehouseIdentity.require(user); + return new Actor(identity.operatorKey(),ActorMode.PDA,Long.toString(identity.overseasWarehouseId())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java new file mode 100644 index 000000000..ad2c82703 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -0,0 +1,144 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.CommandResult; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 真整车建单/加货入口,接收共同DTO且不保留客户子排车协议。 */ +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches") +public class DispatchAggregateController { + private final DispatchAggregateService service; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure; + + /** 注入本域应用服务。 */ + public DispatchAggregateController(DispatchAggregateService service) { this.service=service; } + + /** 空单与先选货建单使用同一个正式入口。 */ + @PostMapping + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestBody DispatchCommands.Create request) { + return ApiResponse.success(service.create(DispatchActorFactory.platform(user),request)); + } + + /** 主单原版本和来源摘要共同核对后加货。 */ + @PostMapping("/{dispatchNo}/items") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse add(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @RequestBody DispatchCommands.AddCargo request) { + return ApiResponse.success(service.addCargo(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 版本化移货只接受真实明细身份。 */ + @PostMapping("/{dispatchNo}/items/{itemId}/remove") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse remove(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable long itemId,@RequestBody DispatchCommands.ReasonCommand request) { + return ApiResponse.success(service.removeCargo(DispatchActorFactory.platform(user),dispatchNo,itemId,request)); + } + /** 取消与主单版本及原命令键一起核对。 */ + @PostMapping("/{dispatchNo}/cancel") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReasonCommand request) { + return ApiResponse.success(service.cancel(DispatchActorFactory.platform(user),dispatchNo,request)); + } + + /** 移货弹框读取真实明细及当前公共版本。 */ + @GetMapping("/{dispatchNo}/items") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 读取实际起点和卸货顺序。 */ + @GetMapping("/{dispatchNo}/route") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse route(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.route(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 调整既有目的地顺序,服务端保留真实名称地址和外接目的地。 */ + @PutMapping("/{dispatchNo}/route") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse route(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.RouteCommand request) { + return ApiResponse.success(service.reorderRoute(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 实载弹框只读取本车实际拣货和测量证据。 */ + @GetMapping("/{dispatchNo}/loading") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 显式确认装车,不复用最后扫码或实际发车操作。 */ + @PostMapping("/{dispatchNo}/loading-complete") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')") + public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { + return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */ + @PostMapping("/{dispatchNo}/depart") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) { + return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 本车证据先上传,主单引用随后走版本化编辑。 */ + @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) { + return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file)); + } + /** 文件元数据不会暴露存储路径。 */ + @GetMapping("/{dispatchNo}/evidence/{kind}/{fileId}") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@PathVariable long fileId) { + return ApiResponse.success(service.evidenceMetadata(DispatchActorFactory.platform(user),dispatchNo,kind,fileId)); + } + /** 每次内容读取重新核对本车范围,禁止匿名文件URL。 */ + @GetMapping("/{dispatchNo}/evidence/{kind}/{fileId}/content") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public org.springframework.http.ResponseEntity evidenceContent(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@PathVariable long fileId) { + var actor=DispatchActorFactory.platform(user);var metadata=service.evidenceMetadata(actor,dispatchNo,kind,fileId); + return org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.parseMediaType(metadata.contentType())) + .cacheControl(org.springframework.http.CacheControl.noStore()).header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(metadata.fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .body(service.evidenceContent(actor,dispatchNo,kind,fileId)); + } + + /** 提交本轮两级审核。 */ + @PostMapping("/{dispatchNo}/submit-review") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.SubmitReview request) { + return ApiResponse.success(service.submitReview(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 调度审核先于理货审核。 */ + @PostMapping("/{dispatchNo}/dispatch-review") + @PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')") + public ApiResponse dispatchReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReviewCommand request) { + return ApiResponse.success(service.review(DispatchActorFactory.platform(user),dispatchNo,false,request)); + } + /** 理货审核通过后才允许真实扫码。 */ + @PostMapping("/{dispatchNo}/loading-review") + @PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')") + public ApiResponse loadingReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReviewCommand request) { + return ApiResponse.success(service.review(DispatchActorFactory.platform(user),dispatchNo,true,request)); + } + /** 零扫码退回保留原整车编号。 */ + @PostMapping("/{dispatchNo}/return-to-review") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse returnToReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReturnToReview request) { + return ApiResponse.success(service.returnToReview(DispatchActorFactory.platform(user),dispatchNo,request)); + } + + /** 原动作权限在服务层按持久动作再次检查,列表权限不能恢复他人命令。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + @PreAuthorize("isAuthenticated()") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(service.command(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index fe78e7aca..2d05acddd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -2,70 +2,33 @@ package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; 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.service.DispatchService; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; 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; -import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/platform/v1/last-leg/dispatches") @Tag(name = "Tail-leg vehicle dispatch") public class DispatchController { - @Resource private DispatchService service; + @Resource private com.baiyue.lastleg.dispatch.service.DispatchAggregateService aggregate; @PutMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") @Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses") - public ApiResponse update( + public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody DispatchRequests.Update request) { - requirePlatform(user); - return ApiResponse.success(service.update(dispatchNo, request)); + @Valid @RequestBody com.baiyue.lastleg.dispatch.port.DispatchCommands.Update request) { + return ApiResponse.success(aggregate.update(DispatchActorFactory.platform(user),dispatchNo,request)); } - @DeleteMapping("/{dispatchNo}/items/{itemId}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") - @Operation(summary = "Remove one unpicked pallet before an outbound order is generated") - public ApiResponse removeItem( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @PathVariable Long itemId) { - requirePlatform(user); - return ApiResponse.success(service.removeItem(dispatchNo, itemId)); - } - - @DeleteMapping("/{dispatchNo}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')") - @Operation(summary = "Cancel a pending dispatch and release its pallet locks") - public ApiResponse cancel( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo) { - requirePlatform(user); - service.cancel(dispatchNo); - return ApiResponse.success(null); - } - - 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 application required"); - } - } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index feac81de7..fbaa18cec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -3,10 +3,10 @@ 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.service.DispatchService; import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.dispatch.port.DispatchViews; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,27 +34,27 @@ import java.nio.charset.StandardCharsets; @RequestMapping("/api/platform/v1/last-leg/dispatches") @Tag(name = "Tail-leg vehicle dispatch query") public class DispatchQueryController { - @Resource private DispatchService service; + @Resource private DispatchAggregateService aggregateService; @Resource private DispatchBolPrintService printService; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @Operation(summary = "Page dispatch orders") - public ApiResponse> page( + public ApiResponse page( @AuthenticationPrincipal AuthenticatedUser user, - @Valid DispatchRequests.Query query) { + @Valid DispatchViews.Query query) { requirePlatform(user); - return ApiResponse.success(service.platformPage(query)); + return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query)); } @GetMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @Operation(summary = "Get dispatch details and all pallet destinations") - public ApiResponse detail( + public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { requirePlatform(user); - return ApiResponse.success(service.platformDetail(dispatchNo)); + return ApiResponse.success(aggregateService.detail(DispatchActorFactory.platform(user),dispatchNo)); } @GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index b6239d6a4..17af0bea5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -2,154 +2,70 @@ package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; 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.service.DispatchService; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningPort; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.security.OverseasPermissions; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -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 org.springframework.web.bind.annotation.*; import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; +/** 草稿仅走冻结的服务器版本化端口,候选继续由主流程提供真实来源。 */ @RestController -@ConditionalOnProperty(name = "lastleg.smart-dispatch-api.enabled", havingValue = "true") +@ConditionalOnProperty(name="lastleg.smart-dispatch-api.enabled",havingValue="true") @RequestMapping("/api/platform/v1/last-leg/dispatches") -@Tag(name = "Tail-leg smart dispatch") public class SmartDispatchController { - @Resource private DispatchService service; + @Resource private DispatchPlanningPort planning; + @Resource private DispatchAggregateService aggregate; + /** 读取实际候选,不在草稿域复制来源筛选逻辑。 */ @GetMapping("/candidates") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") - @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) - public ApiResponse> candidates( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) List planIds, - @RequestParam(required = false) String orderNo, - @RequestParam(required = false) String fbaWarehouseCode, - @RequestParam(required = false) String sourceStage, - @RequestParam(required = false) String draftToken) { - requirePlatform(user); - if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) { - return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken)); - } - return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, - fbaWarehouseCode, sourceStage, draftToken)); + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") + public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId, + @RequestParam(required=false)List planIds,@RequestParam(required=false)String orderNo, + @RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) { + return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken))); } - + /** 只列出当前拥有者未失效草稿。 */ @GetMapping("/drafts") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse> drafts(@AuthenticationPrincipal AuthenticatedUser user) { - requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user))); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") + public ApiResponse> drafts(@AuthenticationPrincipal AuthenticatedUser user){return ApiResponse.success(planning.drafts(DispatchActorFactory.platform(user)));} + /** 以持久命令键创建服务器token及零版本。 */ + @PostMapping("/drafts") + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse createDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody CreateDraft request){return ApiResponse.success(planning.createDraft(DispatchActorFactory.platform(user),request));} + /** 版本化布局保存只移动既有来源。 */ @PostMapping("/drafts/snapshot") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse saveDraft(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftSnapshot request) { - requirePlatform(user); service.saveDraftSnapshot(operatorId(user), request); return ApiResponse.success(null); - } - - @PostMapping("/pre-arrange") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH) - public ApiResponse preArrange( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.PreArrange request) { - requirePlatform(user); - String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank() - ? service.resolveCustomerScopeForSources(request.planPalletIds(), request.returnReceiptIds()) - : service.resolveCustomerScopeForRelabel(request.relabelNo()); - String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); - return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse saveDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSnapshot request){return ApiResponse.success(planning.saveSnapshot(DispatchActorFactory.platform(user),request));} + /** 一次转换全部容器,一容器一整车,不按客户拆头。 */ @PostMapping("/drafts/pre-arrange") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Atomically create one dispatch per loading container in an owned draft", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH) - public ApiResponse> preArrangeDraft( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.PreArrangeDraft request) { - requirePlatform(user); - return ApiResponse.success(service.preArrangeDraft(operatorId(user), request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse preArrangeDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody ConvertDraft request){return ApiResponse.success(planning.convert(DispatchActorFactory.platform(user),request));} + /** 取得本草稿容器的真实来源排他占用。 */ @PostMapping("/draft-locks/lock") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft") - public ApiResponse lockDraftPallet( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftPallet request) { - requirePlatform(user); - String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); - service.lockDraftPallet(customerId, operatorId(user), request); - return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse lockDraftPallet(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSelection request){return ApiResponse.success(planning.select(DispatchActorFactory.platform(user),request));} + /** 新建容器返回同一草稿的新版本。 */ @PostMapping("/drafts/containers") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse createContainer(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftContainer request) { - requirePlatform(user); return ApiResponse.success(service.createDraftContainer(operatorId(user), request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse createContainer(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody CreateContainer request){return ApiResponse.success(planning.createContainer(DispatchActorFactory.platform(user),request));} + /** DELETE同样带公共版本、原因及命令键,且只允许空容器。 */ @DeleteMapping("/drafts/{draftToken}/containers/{containerId}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse deleteContainer(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String draftToken, @PathVariable Long containerId) { - requirePlatform(user); service.deleteDraftContainer(operatorId(user), draftToken, containerId); return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse deleteContainer(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String draftToken,@PathVariable Long containerId, + @Valid @RequestBody DispatchCommands.ReasonCommand request){return ApiResponse.success(planning.removeContainer(DispatchActorFactory.platform(user),draftToken,containerId,request));} + /** 解锁只作用于原草稿原容器及原来源身份。 */ @PostMapping("/draft-locks/unlock") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Unlock one pallet removed from the current intelligent dispatch draft") - public ApiResponse unlockDraftPallet( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftPallet request) { - requirePlatform(user); - String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); - service.unlockDraftPallet(customerId, operatorId(user), request); - return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse unlockDraftPallet(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSelection request){return ApiResponse.success(planning.unselect(DispatchActorFactory.platform(user),request));} + /** 释放本拥有者原版本草稿,不释放其他作业占用。 */ @PostMapping("/draft-locks/release") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Release every pallet lock held by the current intelligent dispatch draft") - public ApiResponse releaseDraft( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftRelease request) { - requirePlatform(user); - service.releaseDraft(operatorId(user), request.draftToken()); - return ApiResponse.success(null); - } - - private String operatorId(AuthenticatedUser user) { - return user.userId() == null ? user.username() : String.valueOf(user.userId()); - } - - 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 application required"); - } - } + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse releaseDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftRelease request){return ApiResponse.success(planning.release(DispatchActorFactory.platform(user),request));} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java deleted file mode 100644 index 49b66e7d4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.baiyue.lastleg.dispatch.dto; - -/** One independent dispatch for one server-owned loading container. */ -public record DispatchDraftGroupResult(Long draftContainerId, DispatchResponse dispatch) { } 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 deleted file mode 100644 index 2088889b7..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ /dev/null @@ -1,109 +0,0 @@ -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.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -import java.time.LocalDateTime; -import java.util.List; -import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation; - -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, Stage stage) { - public Query { if (page == 0) page = 1; if (size == 0) size = 20; } - } - - public enum Stage { - BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED; - public List statuses() { - return switch (this) { - case BEFORE_LOADING -> List.of("PENDING_DEPARTURE"); - case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART"); - case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED"); - case CANCELLED -> List.of("CANCELLED"); - }; - } - } - - public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds, - @Size(max = 64) String fbaAppointmentNo, - @Size(max = 32) String fbaAppointmentStatus, - @Size(max = 32) String vehicleAppointmentStatus, - @Size(max = 64) String draftToken, - @Size(max = 64) String relabelNo, - @Size(max = 500) List<@NotNull Long> returnReceiptIds, - @Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) { - public PreArrange { - planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds); - returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds); - batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations); - } - public PreArrange(List planPalletIds,String fbaAppointmentNo,String fbaAppointmentStatus,String vehicleAppointmentStatus,String draftToken,String relabelNo,List returnReceiptIds) { - this(planPalletIds,fbaAppointmentNo,fbaAppointmentStatus,vehicleAppointmentStatus,draftToken,relabelNo,returnReceiptIds,List.of()); - } - public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, - String vehicleAppointmentStatus, String draftToken, String relabelNo) { - this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, relabelNo, List.of()); - } - public PreArrange(List planPalletIds, String fbaAppointmentNo, - String fbaAppointmentStatus, String vehicleAppointmentStatus) { - this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); - } - public PreArrange(List planPalletIds, String fbaAppointmentNo, - String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) { - this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null); - } - } - - public record DraftPallet(Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId, - Long returnReceiptId, String sourceType) { - public DraftPallet(Long planPalletId, String draftToken, Long draftContainerId) { this(planPalletId, draftToken, draftContainerId, null, "INBOUND_PLAN"); } - public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); } - @jakarta.validation.constraints.AssertTrue(message = "请选择唯一且匹配类型的实物来源") - public boolean isValidSource() { - return returnReceiptId != null ? planPalletId == null && "RETURN_RECEIPT".equals(sourceType) - : planPalletId != null && (sourceType == null || "INBOUND_PLAN".equals(sourceType)); - } - } - - public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {} - - public record PreArrangeDraft(@NotBlank @Size(max = 64) String draftToken, - @NotEmpty @Size(max = 100) List<@NotNull @jakarta.validation.Valid DraftGroup> groups) {} - - public record DraftGroup(@NotNull Long draftContainerId, - @Size(max = 500) List<@NotNull Long> planPalletIds, - @Size(max = 500) List<@NotNull Long> returnReceiptIds, - @Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) { - public DraftGroup { - planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds); - returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds); - batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations); - } - public DraftGroup(Long id,List pallets,List receipts) { this(id,pallets,receipts,List.of()); } - } - - public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) { - } - - public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken, - @NotEmpty @Size(max = 500) List<@NotNull Long> planIds, - @NotBlank @Size(max = 20000) String containersJson) { - } - - public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, - @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, - LocalDateTime departAt, - @Size(max = 64) String fbaAppointmentNo, - @Size(max = 32) String fbaAppointmentStatus, - @Size(max = 32) String vehicleAppointmentStatus, - LocalDateTime estimatedArrivalAt) { - } -} 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 deleted file mode 100644 index df6187c20..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java +++ /dev/null @@ -1,35 +0,0 @@ -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, List bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow, - List appointmentBatches, - Actions actions) { - public record Actions(boolean canEdit, String editBlockReason, - boolean canCancel, String cancelBlockReason, - boolean canRemovePallet, String removePalletBlockReason, - boolean canTransfer, String transferBlockReason, - String outboundNo, String outboundStatus) { } - 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 grossWeightKg, BigDecimal volumeWeightKg, - BigDecimal volumeCbm, String sourceStage) { - } - - public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode, - String destinationWarehouseName, String warehouseNo, String fbaShipmentId, - String poReferenceNo, int packageCount) { - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java new file mode 100644 index 000000000..0fc306e52 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.dispatch.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 真整车主单,不带客户头部;货物与来源各自保留真实客户。 */ +@Data +public class DispatchAggregate { + private Long id; + private String dispatchNo; + private Long tripId; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String status; + private String stage; + private Long recordVersion; + private Long cargoRevision; + private String cargoHash; + private String reviewState; + private Long reviewRound; + private String reviewBasisHash; + private String dispatchReviewJson; + private String loadingReviewJson; + private String resourcesJson; + private String vehiclePlate; + private String route; + private LocalDateTime plannedDepartureAt; + private LocalDateTime estimatedArrivalAt; + private LocalDateTime loadingCompletedAt; + private String loadingCompletionHash; + private String loadingConfirmationJson; + private String arrangementKey; + private String arrangementBusinessHash; + private LocalDateTime departedAt; + private LocalDateTime deliveryCompletedAt; + private Integer itemCount; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private String remark; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java new file mode 100644 index 000000000..6e412e3da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dispatch.entity; + +import java.time.LocalDateTime; + +/** 全局请求键的不可变成功记录,客户归属不能作为键分片。 */ +public record DispatchCommandRecord(Long id, Long dispatchId, String dispatchNo, String action, + String operatorId, String idempotencyKey, long recordVersion, + String commandHash, String resultJson, LocalDateTime createdAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java index bf4ce5818..ffb2daa22 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java @@ -13,8 +13,10 @@ public class DispatchDraft { @TableId(type = IdType.INPUT) private Long id; private String draftToken; - private String customerId; private String operatorId; + private String warehouseId; + private Long recordVersion; + private String sourcesJson; private String planIdsJson; private String containersJson; private String status; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java new file mode 100644 index 000000000..effc6ae6b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 草稿命令不可变身份及原始公开结果,与主单命令账本分域持久化。 */ +@Data +public class DispatchDraftCommand { + private Long id; + private String idempotencyKey; + private String operatorId; + private String action; + private String targetToken; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java index 74599d6bc..e346b2067 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java @@ -15,6 +15,7 @@ public class DispatchDraftContainer { private String draftToken; private Integer sequenceNo; private Integer capacity; + private String sourceKeysJson; private LocalDateTime createdAt; private LocalDateTime updatedAt; } 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 index cb8d25f94..69c6ba311 100644 --- 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 @@ -16,6 +16,10 @@ public class DispatchItem { private Long id; private Long dispatchOrderId; private String customerId; + private String customerName; + private String sourceKey; + private String sourceVersion; + private String sourceSnapshotJson; private Long planId; private Long planPalletId; private Long returnReceiptId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java new file mode 100644 index 000000000..76d84b96d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** 普通换标逐item/实际仓位的冻结贡献,原货源与换标履约单分开保留。 */ +@Data +@TableName("ll_dispatch_item_source") +public class DispatchItemSource { + @TableId(type=IdType.INPUT) private Long id; + private Long dispatchOrderId; + private Long dispatchItemId; + private String customerId; + private Long relabelApplicationId; + private Long relabelItemId; + private String relabelRoundKey; + private Long warehousePalletId; + private Long inventoryPositionId; + private Long inventoryBalanceId; + private Long sourceInventoryBalanceId; + private Long sourceInboundOrderId; + private String sourceInboundOrderNo; + private String sourceInboundCode; + private Long notifiedPackageCount; + private Long packageCount; + private Long productQuantity; + private String bindingBasisHash; + private String unitFactsJson; + 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 deleted file mode 100644 index 78caeb77d..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java +++ /dev/null @@ -1,40 +0,0 @@ -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 index eb6728f0b..d3cf5fb90 100644 --- 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 @@ -9,15 +9,16 @@ 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 Long physicalLockId; private String customerId; private String sourcePalletKey; - /** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */ + /** 活动时为完整逻辑来源键,释放后为空;不同换标来源不再共用一个来源键。 */ @TableField(updateStrategy = FieldStrategy.ALWAYS) private String activeKey; private Long planId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java new file mode 100644 index 000000000..d7f5f9d4e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** 实托排他占用归主单或草稿容器,不以任何逻辑来源或客户代表整托。 */ +@Data +@TableName("ll_dispatch_physical_pallet_lock") +public class DispatchPhysicalPalletLock { + @TableId(type=IdType.INPUT) private Long id; + private String warehouseId; + private Long warehousePalletId; + @TableField(updateStrategy=FieldStrategy.ALWAYS) private Long activeWarehousePalletId; + private Long dispatchOrderId; + private String draftToken; + private Long draftContainerId; + private String operatorId; + private String status; + private LocalDateTime expiresAt; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java index 32e2e5b53..a5fd6f89b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -1,275 +1,151 @@ package com.baiyue.lastleg.dispatch.fleet; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baiyue.lastleg.delivery.entity.DeliveryOrder; -import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; -import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; -import com.baiyue.lastleg.fleet.trip.FleetCompletionModels; -import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import org.springframework.http.HttpStatus; +import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.fleet.exception.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.*; +import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; -import java.time.LocalDateTime; -import java.time.Instant; -import java.time.ZoneId; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; -import java.util.Locale; -import java.util.Map; -import java.util.HashMap; -import java.util.Objects; - +/** Fleet附加能力读取完整整车,所有客户归属均来自真实明细及客户单据。 */ @Component -@Transactional(propagation = Propagation.MANDATORY) +@Transactional(propagation=Propagation.MANDATORY) public class DispatchFleetAdapter implements FleetDispatchGateway { - @jakarta.annotation.Resource - private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution; - @jakarta.annotation.Resource - private com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery cargoSources; - private final DispatchOrderMapper orders; - private final DispatchPdaTaskMapper tasks; + private final DispatchAggregateRepository aggregates; private final OutboundOrderMapper outbounds; private final DeliveryOrderMapper deliveries; - private final DeliveryOperationMapper deliveryOperations; - private final DispatchItemMapper items; - private final DispatchPdaPickMapper picks; - private final DispatchPdaWorkflowService workflow; - @jakarta.annotation.Resource @org.springframework.context.annotation.Lazy - private com.baiyue.lastleg.dispatch.service.DispatchService dispatchService; - - public DispatchFleetAdapter(DispatchOrderMapper orders, DispatchPdaTaskMapper tasks, - OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries, DispatchItemMapper items, - DispatchPdaPickMapper picks, DispatchPdaWorkflowService workflow, DeliveryOperationMapper deliveryOperations) { - this.orders = orders; this.tasks = tasks; this.outbounds = outbounds; this.deliveries = deliveries; - this.items = items; this.picks = picks; this.workflow = workflow; - this.deliveryOperations = deliveryOperations; + private final DeliveryOperationMapper operations; + private final DispatchCargoSourceRepository sources; + private final DispatchAggregateService workflow; + private final ObjectMapper json; + public DispatchFleetAdapter(DispatchAggregateRepository aggregates,OutboundOrderMapper outbounds, + DeliveryOrderMapper deliveries,DeliveryOperationMapper operations,DispatchCargoSourceRepository sources, + @Lazy DispatchAggregateService workflow,ObjectMapper json) { + this.aggregates=aggregates;this.outbounds=outbounds;this.deliveries=deliveries;this.operations=operations; + this.sources=sources;this.workflow=workflow;this.json=json; } - - @Override - public void removeItem(String dispatchNo,long itemId) { dispatchService.removeItem(dispatchNo,itemId); } - - @Override - public Snapshot lockForPlanning(String dispatchNo) { - return lockBeforeDeparture(dispatchNo, false); + /** 批量只读主单当前审核,绑定表没有审核副本。 */ + @Override public Map workflows(List numbers) { + if(numbers.size()>1000)throw conflict("整车审核批量读取超过上限"); + Map result=new HashMap<>(); + for(var row:aggregates.findMany(numbers))result.put(row.getDispatchNo(),new Workflow(row.getStatus(),row.getStage(),row.getRecordVersion(),review(row.getDispatchReviewJson()),review(row.getLoadingReviewJson()))); + return Map.copyOf(result); } - - @Override - public Snapshot lockForAssignment(String dispatchNo) { - return lockBeforeDeparture(dispatchNo, true); + private FleetTripModels.ReviewState review(String stored) { + try {var value=json.readValue(stored,DispatchContracts.Review.class);return new FleetTripModels.ReviewState(value.status().name(),value.reason(),value.reviewerId(),value.reviewedAt(),null);} + catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("整车审核事实读取失败",error);} } - - private Snapshot lockBeforeDeparture(String dispatchNo, boolean allowPicking) { - var dispatch = orders.lockGlobalByDispatchNo(dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var allowedStatuses = allowPicking ? java.util.Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART") : java.util.Set.of("PENDING_DEPARTURE"); - if (dispatch.getStatus() == null || !allowedStatuses.contains(dispatch.getStatus()) - || (!allowPicking && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) - || Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) - throw conflict("排车单 " + dispatchNo + " 已开始执行,不能修改车次关联或改派"); - // Keep the same dispatch -> outbound -> PDA task lock order as departure. - var outbound = outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()); - if (outbound != null && !"WAIT_OUTBOUND".equals(outbound.getStatus())) - throw conflict("排车单 " + dispatchNo + " 的出库状态不允许修改关联"); - var task = tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()); - var allowedTaskStatuses = allowPicking ? java.util.Set.of("PDA_PENDING", "PICKING", "COMPLETED") : java.util.Set.of("PDA_PENDING"); - if (task != null && (task.getStatus() == null || !allowedTaskStatuses.contains(task.getStatus()))) - throw conflict("排车单 " + dispatchNo + " 已开始拣货,不能修改车次关联或改派"); - if (task != null && !Objects.equals(task.getWarehouseId(), dispatch.getOverseasWarehouseId())) - throw conflict("拣货任务所属仓不一致"); - if (task == null && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) - throw conflict("排车单缺少已拣货的作业依据"); - if (dispatch.getPackageCount() == null || dispatch.getPackageCount() <= 0) - throw conflict("排车单 " + dispatchNo + " 没有待发运货物"); - return new Snapshot(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), dispatch.getOverseasWarehouseId(), dispatch.getPackageCount()); + /** 根锁已由上游trip/资源取得,禁止在已有扫码后以旧计划命令调整。 */ + @Override public Snapshot lockForPlanning(String number) { + var row=required(number,true); + if(!"ACTIVE".equals(row.getStatus()) || !"BEFORE_LOADING".equals(row.getStage()) || aggregates.realPicked(row.getId())!=0) + throw conflict("仅本车装车前且未扫码时可安排货物"); + return new Snapshot(row.getId(),row.getDispatchNo(),row.getOverseasWarehouseId(),row.getPackageCount()); } - - @Override - public void assign(Snapshot dispatch, Assignment assignment) { - var now = LocalDateTime.now(); - // Explicit SET is needed when unbinding: MyBatis entity updates omit null fields. - if (orders.update(null, new LambdaUpdateWrapper().eq(DispatchOrder::getId, dispatch.dispatchId()) - .eq(DispatchOrder::getCustomerId, dispatch.customerId()).in(DispatchOrder::getStatus, "PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART") - .set(DispatchOrder::getVehiclePlate, assignment.vehiclePlate()).set(DispatchOrder::getDriverName, assignment.driverName()) - .set(DispatchOrder::getDriverPhone, assignment.driverPhone()).set(DispatchOrder::getUpdatedAt, now)) != 1) - throw conflict("排车单已变化,请刷新后重试"); - var outbound = outbounds.lockByDispatch(dispatch.customerId(), dispatch.dispatchId()); - if (outbound == null) return; - for (var delivery : deliveries.lockByOutbound(dispatch.customerId(), outbound.getId())) { - if (!"WAIT_DELIVERY".equals(delivery.getStatus()) || delivery.getDispatchedAt() != null || delivery.getSignedAt() != null) - throw conflict("派送单 " + delivery.getDeliveryNo() + " 已执行,不能覆盖车辆司机"); - if (deliveries.update(null, new LambdaUpdateWrapper().eq(DeliveryOrder::getId, delivery.getId()) - .eq(DeliveryOrder::getCustomerId, dispatch.customerId()).eq(DeliveryOrder::getVersion, delivery.getVersion()) - .set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName()) - .set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, delivery.getVersion() + 1) - .set(DeliveryOrder::getUpdatedAt, now)) != 1) throw conflict("派送单已变化,请刷新后重试"); + @Override public List customers(String number) { + var row=required(number,false);return aggregates.cargo(row.getId()).stream().map(i->i.getCustomerId()).distinct().sorted(Comparator.comparingLong(Long::parseLong)).toList(); + } + /** 逐客户读取全部来源组成,不用主单或整托展示的一个客户替代。 */ + @Override public List sourceCargo(String number,String customer) { + var row=required(number,false);var items=aggregates.cargo(row.getId()).stream().filter(i->customer.equals(i.getCustomerId())).toList(); + var facts=sources.sources(customer,row.getId());var result=new ArrayList(); + for(var item:items) { + String destination=destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()); + if(item.getReturnReceiptId()!=null || Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(),""))) { + result.add(new SourceCargo(item.getInboundCode(),item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),item.getPackageCount(),destination));continue; + } + var selected=facts.stream().filter(f->Objects.equals(f.dispatchItemId(),item.getId())).toList(); + if(selected.isEmpty() || selected.stream().mapToLong(f->f.packages()).sum()!=item.getPackageCount())throw conflict("本客户实际来源与排车箱数未核平"); + for(var fact:selected) { + if(fact.inboundCode()==null || fact.inboundCode().isBlank() || fact.packages()<=0 || !Objects.equals(fact.warehousePalletId(),item.getWarehousePalletId()))throw conflict("本客户来源缺少真实入仓码或实托"); + result.add(new SourceCargo(fact.inboundCode(),fact.warehousePalletId().toString(),fact.palletNo(),Math.toIntExact(fact.packages()),destination)); + } + } + return List.copyOf(result); + } + @Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) { + var context=workflow.fleetContext(number,lock);var row=required(number,false); + var cargo=aggregates.cargo(row.getId()).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(), + destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList(); + String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null; + var vehicle=context.resources().vehicle();var driver=context.resources().driver(); + return new FleetExecutionModels.Manifest(number,row.getOverseasWarehouseId(),vehicle==null?null:vehicle.plate(),driver==null?null:driver.name(),driver==null?null:driver.phone(),cargo,reason); + } + /** 先收齐所有客户出库锁,再锁所有派送;客户和仓归属从每行复验。 */ + private Facts facts(String number,boolean lock) { + var row=required(number,lock); + var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList(); + var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList(); + var expected=aggregates.cargo(row.getId()).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount()))); + var byCustomer=new HashMap(); + for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致"); + if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物"); + var counts=new HashMap();var numbers=new HashSet(); + for(var delivery:deliveryRows) { + var outbound=byCustomer.get(delivery.getCustomerId()); + if(outbound==null || !numbers.add(delivery.getDeliveryNo()) || !Objects.equals(delivery.getDispatchOrderId(),row.getId()) || !Objects.equals(delivery.getOutboundOrderId(),outbound.getId()) || !Objects.equals(delivery.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || delivery.getPackageCount()==null || delivery.getPackageCount()<=0 || delivery.getVersion()==null)throw conflict("客户派送归属或数量与本车出库不一致"); + counts.merge(delivery.getCustomerId(),delivery.getPackageCount().longValue(),Math::addExact); + } + if(!counts.equals(expected))throw conflict("本车全部客户派送数量未核平"); + return new Facts(row,deliveryRows); + } + private record Facts(DispatchAggregate root,List deliveries) { } + @Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) { + var row=required(number,lock); + if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成"); + var facts=facts(number,lock);var outcomes=new ArrayList(); + for(var delivery:facts.deliveries()) { + var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts); + Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null; + outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining())); + } + String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null; + return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason); + } + @Override public FleetTransitEvidence transitEvidence(String number,boolean lock) { + var facts=facts(number,lock);var row=facts.root(); + if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null || row.getShippedPackageCount()<=0)throw conflict("交接必须具有本车真实发运依据"); + var result=new ArrayList(); + for(var delivery:facts.deliveries()) { + if(!Set.of("DELIVERING","REJECTED","DELIVERED","RETURN_PENDING","RETURNED").contains(Objects.toString(delivery.getStatus(),"")))throw conflict("本车客户派送状态与实际发运不一致"); + var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts); + Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null; + if(("DELIVERED".equals(delivery.getStatus()) || "RETURNED".equals(delivery.getStatus())) && (terminal==null || quantities.remaining()!=0))throw conflict("派送终态缺少完整真实签收或退回证据"); + String key=destination(delivery.getDestinationType(),delivery.getDestinationWarehouseCode(),null,delivery.getDeliveryAddress(),delivery.getPostalCode());if(key==null)throw conflict("派送缺少真实卸货目的地"); + var cargo=new FleetHandoverPolicy.Delivery(delivery.getCustomerId(),number,delivery.getDeliveryNo(),key,delivery.getPackageCount(),terminal,originallySigned(delivery)?List.of():receipts.stream().map(r->{if(r.dispatchItemId()==null || r.palletId()==null)throw conflict("实收退回缺少原明细和实托");return new FleetHandoverPolicy.ReturnedCargo(r.dispatchItemId().toString(),r.palletId().toString(),Math.toIntExact(r.packages()),instant(r.receivedAt()));}).toList()); + result.add(new FleetTransitEvidence.Delivery(cargo,delivery.getStatus(),delivery.getVersion(),new FleetTransitEvidence.Transport(delivery.getVehiclePlate(),delivery.getDriverName(),delivery.getDriverPhone()))); + } + return new FleetTransitEvidence(number,row.getOverseasWarehouseId(),row.getShippedPackageCount(),List.copyOf(result)); + } + @Override public void handover(FleetTransitEvidence evidence,Assignment assignment) { + var current=transitEvidence(evidence.dispatchNo(),true);if(!current.equals(evidence))throw conflict("本车交接事实已变化,请重新核对"); + for(var fact:evidence.deliveries()) { + if(fact.cargo().terminalAt()!=null)continue; + var row=deliveries.lockByNo(fact.cargo().customerId(),fact.cargo().deliveryNo()); + if(row==null || !Objects.equals(row.getVersion(),fact.recordVersion()) || row.getSignedAt()!=null)throw conflict("本客户派送已变化"); + if(deliveries.update(null,new LambdaUpdateWrapper().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.now()))!=1)throw conflict("派送交接保存失败"); } } - - @Override - public FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock) { - var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); - var task = lock ? tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : tasks.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); - var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()); - Map picked = new HashMap<>(); - if (task != null) for (var pick : picks.listTask(dispatch.getCustomerId(), task.getId())) picked.merge(pick.getDispatchItemId(), pick.getPackages(), Math::addExact); - var cargo = selected.stream().map(item -> new FleetExecutionModels.Cargo(dispatchNo, dispatch.getCustomerId(), item.getId().toString(), - item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), - destination(item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()), - "PRIVATE".equals(item.getDestinationType()) ? item.getDeliveryAddress() : first(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()))).toList(); - String reason = null; - if (!"READY_TO_DEPART".equals(dispatch.getStatus()) || task == null || !"COMPLETED".equals(task.getStatus())) reason = "请完成拣货并核平全部箱数"; - else if (!Objects.equals(dispatch.getOverseasWarehouseId(), task.getWarehouseId())) reason = "拣货任务所属仓不一致"; - else if (selected.isEmpty() || selected.stream().anyMatch(item -> item.getPackageCount() == null || item.getPackageCount() <= 0 - || picked.getOrDefault(item.getId(), 0L).longValue() != item.getPackageCount().longValue())) reason = "每条货物须与实际拣货箱数核平"; - else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物"; - else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单"; - else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认"; - if (reason == null) reason = deliveryExecution.departureBlockReason(outbound, lock); - return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason); - } - - @Override - public FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator, - String idempotencyKey, String remark, Instant departedAt) { - var result = workflow.depart(warehouseId, dispatchNo, operator, new DispatchPdaRequests.Depart(idempotencyKey, remark), - LocalDateTime.ofInstant(departedAt, ZoneId.systemDefault())); - return new FleetExecutionModels.DepartedDispatch(result.dispatchNo(), result.shippedPackages()); - } - - private static String destination(String type, String code, String fba, String address, String postalCode) { - return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode); - } - /** 用逐入仓组成核对外接承诺,不使用整托展示来源。 */ - @Override public java.util.List sourceCargo(String dispatchNo) { - var dispatch=orders.findGlobalByDispatchNo(dispatchNo); - if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在"); - return cargoSources.sources(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId())); - } - @Override - public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) { - var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); - var rows = outbound == null ? java.util.List.of() : lock - ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId()); - if (outbound != null && (rows.stream().mapToLong(row -> Objects.requireNonNullElse(row.getPackageCount(), 0)).sum() - != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount()))) - return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), java.util.List.of(), "派送单数量未覆盖本次全部实发货物"); - var outcomes = rows.stream().map(row -> { - var quantities = quantities(row, deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId())); - return new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(), - "PRIVATE".equals(row.getDestinationType()) ? row.getDeliveryAddress() : first(row.getDestinationWarehouseCode(), row.getDestinationWarehouseName()), - originallySigned(row) ? "DELIVERED" : row.getStatus(), Objects.requireNonNullElse(row.getPackageCount(), 0), - originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) - ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null, - quantities.signed(), quantities.returned(), quantities.remaining()); - }).toList(); - String reason = null; - if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) reason = "排车出库尚未完成"; - else if (outcomes.isEmpty()) reason = "缺少对应派送单"; - else if (outcomes.stream().mapToLong(FleetCompletionModels.Outcome::packages).sum() != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) - || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) reason = "派送单数量未覆盖本次全部实发货物"; - else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || row.remainingPackages() != 0 - || row.signedPackages() + row.returnedPackages() != row.packages() - || !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null)) - reason = "所有派送单须已签收,或完成真实退回入仓"; - return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason); - } - // Existing delivery commands persist LocalDateTime.now(); convert in that server zone, never the browser zone. - @Override - public com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock) { - var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) - : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); - if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus()) - || dispatch.getShippedPackageCount() == null || dispatch.getShippedPackageCount() <= 0 - || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) - throw conflict("交接必须具有已完成出库的真实发运依据"); - var rows = lock ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) - : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId()); - var facts = new java.util.ArrayList(); - long packages = 0; - for (var row : rows) { - if (!Objects.equals(row.getCustomerId(), dispatch.getCustomerId()) - || !Objects.equals(row.getDispatchOrderId(), dispatch.getId()) - || !Objects.equals(row.getOutboundOrderId(), outbound.getId()) - || !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId()) - || row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null - || row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERING", "REJECTED", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus())) - throw conflict("派送货物归属或执行状态与原始发运不一致"); - Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) - ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null; - if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null) - throw conflict("派送终态缺少真实签收或退回完成证据"); - var receipts = deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId()); - var quantities = quantities(row, receipts); - if (terminal != null && quantities.remaining() != 0) throw conflict("派送终态数量未覆盖原实发货物"); - String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null, - row.getDeliveryAddress(), row.getPostalCode()); - if (key == null) throw conflict("派送单缺少真实卸货目的地"); - var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(), - dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal, - originallySigned(row) ? java.util.List.of() : receipts.stream().map(receipt -> { - if (receipt.dispatchItemId() == null || receipt.palletId() == null) throw conflict("实际退回缺少原发运实托明细归属"); - return new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.ReturnedCargo( - receipt.dispatchItemId().toString(), receipt.palletId().toString(), Math.toIntExact(receipt.packages()), instant(receipt.receivedAt())); - }).toList()); - facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(), - new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone()))); - packages = Math.addExact(packages, row.getPackageCount()); - } - if (facts.isEmpty() || packages != dispatch.getShippedPackageCount()) - throw conflict("派送明细未核平本次实发货物"); - return new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence(dispatchNo, dispatch.getCustomerId(), - dispatch.getOverseasWarehouseId(), dispatch.getShippedPackageCount(), java.util.List.copyOf(facts)); - } - - @Override - public void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment) { - var current = transitEvidence(evidence.dispatchNo(), true); - if (!current.equals(evidence)) throw conflict("交接货物已变化,请重新核对"); - for (var fact : evidence.deliveries()) { - if (fact.cargo().terminalAt() != null) continue; - var row = deliveries.lockByNo(evidence.customerId(), fact.cargo().deliveryNo()); - if (row == null || !Objects.equals(row.getVersion(), fact.recordVersion()) || row.getSignedAt() != null) - throw conflict("派送货物已变化,请重新核对交接"); - if (deliveries.update(null, new LambdaUpdateWrapper() - .eq(DeliveryOrder::getId, row.getId()).eq(DeliveryOrder::getCustomerId, evidence.customerId()) - .eq(DeliveryOrder::getVersion, fact.recordVersion()).eq(DeliveryOrder::getStatus, fact.status()) - .isNull(DeliveryOrder::getSignedAt) - .set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName()) - .set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, fact.recordVersion() + 1) - .set(DeliveryOrder::getUpdatedAt, LocalDateTime.now())) != 1) - throw conflict("派送交接保存失败,请刷新核对"); - } - } - - private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); } - private static DeliveryCargoQuantities quantities(DeliveryOrder row, java.util.List receipts) { - try { return DeliveryCargoQuantities.from(row, receipts); } - catch (IllegalArgumentException error) { throw conflict(error.getMessage()); } - } - private static boolean originallySigned(DeliveryOrder order) { - return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus()); - } - private static String first(String a, String b) { return a != null && !a.isBlank() ? a : b == null || b.isBlank() ? null : b; } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;} + private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);} + /** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */ + private static Instant instant(LocalDateTime value){return value==null?null:value.atZone(ZoneId.systemDefault()).toInstant();} + private static String first(String a,String b){return a!=null&&!a.isBlank()?a:b==null||b.isBlank()?null:b;} + private static boolean originallySigned(DeliveryOrder row){return row.getSignedAt()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(row.getStatus());} + private static DeliveryCargoQuantities quantities(DeliveryOrder row,List receipts){try{return DeliveryCargoQuantities.from(row,receipts);}catch(IllegalArgumentException error){throw conflict(error.getMessage());}} + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java index 61dcbe5ed..0d504905e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java @@ -1,58 +1,52 @@ package com.baiyue.lastleg.dispatch.fleet; - -import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.dispatch.service.DispatchService; -import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; -import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.trip.*; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.*; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.server.ResponseStatusException; import org.springframework.http.HttpStatus; import java.util.*; - +/** 外接预订绑定真实客户来源,整车身份由当前车次唯一关联解析。 */ @Component @Transactional(propagation=Propagation.MANDATORY) public class FleetDispatchPlanningAdapter implements FleetDispatchPlanningGateway { - private final FleetDispatchPlanningMapper mapper; - private final DispatchService dispatches; - private final FleetDispatchGateway cargo; - public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,@Lazy DispatchService dispatches,FleetDispatchGateway cargo) { - this.mapper=mapper;this.dispatches=dispatches;this.cargo=cargo; + private final FleetDispatchPlanningMapper mapper;private final DispatchSourceRepository sources; + private final DispatchService rules;private final FleetDispatchGateway cargo;private final DispatchAggregateService workflow; + public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,DispatchSourceRepository sources,@Lazy DispatchService rules, + FleetDispatchGateway cargo,@Lazy DispatchAggregateService workflow){this.mapper=mapper;this.sources=sources;this.rules=rules;this.cargo=cargo;this.workflow=workflow;} + @Override public String resolveIncoming(String tripNo,String customer,FleetBookingIncomingGateway.Evidence receipt,String operator,String key) { + if(receipt==null || receipt.inboundCode()==null || !"READY_FOR_DISPATCH".equals(receipt.status()) || receipt.pallets().isEmpty())throw conflict("请先完成真实收货和实收核对"); + String number=mapper.dispatchForTrip(tripNo);if(number==null)throw conflict("本车缺少唯一整车主单"); + var authentication=SecurityContextHolder.getContext().getAuthentication(); + var actor=DispatchActorFactory.platform(authentication!=null && authentication.getPrincipal() instanceof AuthenticatedUser user?user:null); + if(!Objects.equals(actor.operatorId(),operator))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"外接安排操作者不一致"); + var context=workflow.lockForArrangement(actor,number);var selected=new HashSet<>(mapper.selectedPlans(context.dispatchId(),customer)); + var additions=new ArrayList(); + for(var id:mapper.receivedPlanPallets(customer,receipt.inboundCode())) { + if(selected.contains(id))continue;var candidates=sources.selectedPlan(customer,id); + if(candidates.size()!=1)throw conflict("外接实收来源已变化,请重新核对");var candidate=candidates.get(0); + if(!Objects.equals(candidate.overseasWarehouseId(),context.warehouseId()))throw conflict("外接来源起运仓不一致"); + additions.add(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,id,null,null,null,rules.wholeSourceVersion(candidate,"PLAN:"+id,false))); + } + if(!additions.isEmpty())workflow.addCargo(actor,number,new DispatchCommands.AddCargo(context.recordVersion(),key,additions,List.of())); + validateIncomingDispatch(number,customer,receipt);return number; } - public PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size) { - var result=mapper.candidates(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page,size),tripNo,warehouseId,keyword); - return new PageResult<>(result.getRecords(),result.getCurrent(),result.getSize(),result.getTotal(),result.getPages()); - } - public String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator) { - if(receipt==null||receipt.inboundCode()==null||!"READY_FOR_DISPATCH".equals(receipt.status())||receipt.pallets().isEmpty()) - throw conflict("请先完成真实收货和实收核对"); - var existing=mapper.sourceDispatches(customerId,receipt.inboundCode()); - if(existing.size()>1)throw conflict("本次来货已有多张排车安排,请调度核查:"+String.join("、",existing)); - String number; - if(existing.isEmpty()) { - var ids=mapper.receivedPlanPallets(customerId,receipt.inboundCode()); - if(ids.isEmpty())throw conflict("实收来源没有可用于排车的真实托盘"); - number=dispatches.preArrange(customerId,operator,new DispatchRequests.PreArrange(ids,null,null,null,null)).dispatchNo(); - } else number=existing.get(0); - validateIncomingDispatch(number,customerId,receipt); - return number; - } - public void validateIncomingDispatch(String number,String customerId,FleetBookingIncomingGateway.Evidence receipt) { - var snapshot=cargo.lockForPlanning(number); - if(!customerId.equals(snapshot.customerId())||!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("排车客户或起运仓与来货不一致"); - var expected=new TreeMap(); - for(var p:receipt.pallets())expected.merge(p.warehousePalletId(),p.packages(),Math::addExact); + @Override public void validateIncomingDispatch(String number,String customer,FleetBookingIncomingGateway.Evidence receipt) { + var snapshot=cargo.lockForPlanning(number);if(!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("整车起运仓与外接实收不一致"); + var expected=new TreeMap();for(var pallet:receipt.pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact); var selected=new TreeMap(); - for(var item:cargo.sourceCargo(number)) { - if(!Objects.equals(receipt.inboundCode(),item.inboundCode())||!Objects.equals(receipt.destinationKey(),item.destinationKey())||item.warehousePalletId()==null) - throw conflict("候选排车包含其他来源或目的地,请调度核查 "+number); + for(var item:cargo.sourceCargo(number,customer))if(Objects.equals(receipt.inboundCode(),item.inboundCode())) { + if(item.warehousePalletId()==null || !Objects.equals(receipt.destinationKey(),item.destinationKey()))throw conflict("本客户外接实收托盘或目的地不一致"); selected.merge(item.warehousePalletId(),item.packages(),Math::addExact); } - if(!expected.equals(selected))throw conflict("候选排车未完整覆盖本票全部实收货物,请调度核查 "+number); + if(!expected.equals(selected))throw conflict("本车未完整覆盖本客户本票外接实收来源"); } - private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} + private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java index 60f01d757..28f587908 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java @@ -1,28 +1,13 @@ package com.baiyue.lastleg.dispatch.fleet; - -import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; import org.apache.ibatis.annotations.*; import java.util.List; - +/** 外接实收仅加入本车真实主单,不生成客户子排车。 */ @Mapper public interface FleetDispatchPlanningMapper { - @Select({""}) - IPage candidates(IPage page,@Param("tripNo") String tripNo,@Param("warehouseId") String warehouseId,@Param("keyword") String keyword); - - @Select("SELECT DISTINCT d.dispatch_no FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id " + - "WHERE d.customer_id=#{customerId} AND i.inbound_code=#{inboundCode} AND d.status!='CANCELLED' ORDER BY d.dispatch_no") - List sourceDispatches(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode); - + @Select("SELECT dispatch_no FROM ll_fleet_trip_dispatch WHERE trip_no=#{tripNo}") + String dispatchForTrip(@Param("tripNo") String tripNo); + @Select("SELECT DISTINCT plan_pallet_id FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id IS NOT NULL") + List selectedPlans(@Param("dispatch") long dispatch,@Param("customer") String customer); @Select("SELECT DISTINCT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "WHERE ip.customer_id=#{customerId} AND l.inbound_code=#{inboundCode} AND p.warehouse_pallet_id IS NOT NULL ORDER BY p.id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java new file mode 100644 index 000000000..bedd86d03 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dispatch.gateway; + +import org.springframework.web.multipart.MultipartFile; + +/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */ +public interface DispatchEvidenceGateway { + enum Kind { VEHICLE, DRIVER } + record File(Long fileId,String fileName,String contentType,long sizeBytes) { } + /** 上传只写本车、本类证据。 */ + File upload(long dispatchId,Kind kind,String operator,MultipartFile file); + /** 审核前逐文件复验平台、本车与类型复合归属。 */ + File metadata(long dispatchId,Kind kind,long fileId); + /** 下载仍通过本车读权限入口。 */ + byte[] download(long dispatchId,Kind kind,long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java new file mode 100644 index 000000000..9f2516f8e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java @@ -0,0 +1,96 @@ +package com.baiyue.lastleg.dispatch.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** 本域只调用File内部通用业务文件入口,不读取File表或返回存储地址。 */ +@Component +public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { + private static final long MAX=10L*1024*1024; + private static final Set TYPES=Set.of("application/pdf","image/jpeg","image/png"); + private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}"; + private final RestClient client; + private final String token; + /** 复用既有内部客户端超时与身份配置,不新增环境地址。 */ + public HttpDispatchEvidenceGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}")String url,@Value("${baiyue.security.internal-service-token:}")String token) { + client=factory.build(builder,url,properties.getCustomerVisitFile());this.token=token; + } + /** 类型由枚举生成,禁止请求指定任意File业务域。 */ + private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; } + /** 本车证据上传完成后仍以File返回元数据核对复合归属。 */ + @Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) { + if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid(); + var body=new MultipartBodyBuilder(); + try { body.part("file",new ByteArrayResource(file.getBytes()) { + /** 保留用户文件名供审阅,不把路径当业务事实。 */ + @Override public String getFilename(){return file.getOriginalFilename();} + }).contentType(MediaType.parseMediaType(file.getContentType())); }catch(java.io.IOException error){throw invalid();} + var result=client.post().uri(PATH,type(kind),dispatch).header("X-Internal-Service-Token",token).header("X-Operator-Id",operator) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().onStatus(HttpStatusCode::isError,(q,r)->{throw unavailable(r.getStatusCode());}).body(Envelope.class); + return verified(result,dispatch,kind,null); + } + /** 不接受其他车、其他证据类型或失效文件。 */ + @Override public File metadata(long dispatch,Kind kind,long id) { + var result=client.get().uri(PATH+"/{file}",type(kind),dispatch,id).header("X-Internal-Service-Token",token).retrieve() + .onStatus(HttpStatusCode::isError,(q,r)->{throw unavailable(r.getStatusCode());}).body(Envelope.class); + return verified(result,dispatch,kind,id); + } + /** 先核对元数据再读取有界内容。 */ + @Override public byte[] download(long dispatch,Kind kind,long id) { + var expected=metadata(dispatch,kind,id); + return client.get().uri(PATH+"/{file}/content",type(kind),dispatch,id).header("X-Internal-Service-Token",token).exchange((request,response)->{ + if(response.getStatusCode().isError())throw unavailable(response.getStatusCode()); + // 只读取上限加一,防止损坏上游响应绕过已核实的10MiB元数据上限。 + var bytes=response.getBody().readNBytes((int)MAX+1); + return verifiedContent(expected,response.getHeaders().getContentType(),response.getHeaders().getContentLength(),bytes); + }); + } + /** HTTP200业务错误JSON不能伪装成PDF或图片;MIME、长度及签名必须与元数据一致。 */ + static byte[] verifiedContent(File expected,MediaType contentType,long contentLength,byte[] bytes) { + if(bytes==null || bytes.length==0 || bytes.length>MAX)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + if(contentType!=null && (MediaType.APPLICATION_JSON.isCompatibleWith(contentType) || contentType.getSubtype().endsWith("+json"))) { + String code=null;try{code=new com.fasterxml.jackson.databind.ObjectMapper().readTree(bytes).path("code").asText(null);}catch(java.io.IOException ignored){/* 无法识别的上游错误响应按故障处理。 */} + throw businessFailure(code); + } + if(bytes.length!=expected.sizeBytes() || contentLength>=0 && contentLength!=bytes.length + || contentType==null || !MediaType.parseMediaType(expected.contentType()).isCompatibleWith(contentType))throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + boolean signature=switch(expected.contentType()) { + case "application/pdf" -> bytes.length>=5 && new String(bytes,0,5,java.nio.charset.StandardCharsets.US_ASCII).equals("%PDF-"); + case "image/png" -> bytes.length>=4 && (bytes[0]&255)==0x89 && bytes[1]==0x50 && bytes[2]==0x4e && bytes[3]==0x47; + case "image/jpeg" -> bytes.length>=2 && (bytes[0]&255)==0xff && (bytes[1]&255)==0xd8; + default -> false; + }; + if(!signature)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);return bytes; + } + /** File只能证明本车文件事实,审核决策仍属于本域。 */ + static File verified(Envelope envelope,long dispatch,Kind kind,Long id) { + if(envelope==null)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + if(!"200".equals(envelope.code()))throw businessFailure(envelope.code()); + if(envelope.data()==null)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + var value=envelope.data(); + if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId()) + || value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank() + || !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid(); + return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes()); + } + /** 无效证据不能作为已核实资料。 */ + private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} + /** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */ + private static ResponseStatusException businessFailure(String code){return code!=null && code.startsWith("A04") + ?new ResponseStatusException("A0404".equals(code)?HttpStatus.NOT_FOUND:HttpStatus.BAD_REQUEST,"证据文件不可用或归属核验失败") + :unavailable(HttpStatus.SERVICE_UNAVAILABLE);} + /** 上游不可用与业务文件不合格分别保留HTTP语义。 */ + private static ResponseStatusException unavailable(HttpStatusCode status){return new ResponseStatusException(status.is4xxClientError()?HttpStatus.BAD_REQUEST:HttpStatus.SERVICE_UNAVAILABLE,"证据文件不可用或归属核验失败");} + record Envelope(String code,Metadata data) { } + record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java new file mode 100644 index 000000000..d4c0197ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 真整车主单SQL,不按单客户头部筛选或创建。 */ +@Mapper +public interface DispatchAggregateMapper { + /** Fleet附加能力批量读取当前整车审核事实,不读取绑定表旧审核。 */ + @Select("") + java.util.List findMany(@Param("numbers") java.util.List numbers); + String BASE_FILTER = """ + AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%') + AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%') + AND d.overseas_warehouse_id=#{q.warehouseId} + AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId}) + AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo}) + """; + String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END"; + String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END"; + String CURRENT_FILTER = """ + AND d.status=#{q.status} + AND d.stage=#{q.stage} + AND ( + """ + REVIEW_DISPATCH + ")=#{q.reviewStatus}" + + " AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}"; + + record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId, + String fbaAppointmentNo, String status, String stage, String section, String reviewStatus, + long offset, int limit) { } + record Summary(String status, String stage, String reviewState, long quantity) { } + + /** 首次创建整车所有权及初始版本,不写customer_id。 */ + @Insert(""" + INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code, + overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round, + dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at, + estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count, + remark,created_by,created_at,updated_at) + VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, + #{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash}, + #{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson}, + #{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount}, + #{r.packageCount},#{r.pickedPackageCount},#{r.shippedPackageCount},#{r.remark},#{r.createdBy},#{r.createdAt},#{r.updatedAt}) + """) + int insert(@Param("r") DispatchAggregate row); + + /** 平台按业务号读取真实整车,不借客户ID作为头部归属。 */ + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{number}") + DispatchAggregate find(@Param("number") String number); + + /** 已取得Fleet父锁及资源锁后再锁主单。 */ + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{number} FOR UPDATE") + DispatchAggregate lock(@Param("number") String number); + + /** SQL完成当前条件下的整车分页,每车只返回一行。 */ + @Select("") + List page(@Param("q") Search query); + + /** 分页总数复用完全相同筛选,不能用当前页长度替代。 */ + @Select("") + long count(@Param("q") Search query); + + /** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */ + @Select("") + List summaries(@Param("q") Search query); + + /** 同一主单版本原子刷新货物摘要、计数并使原审核失效。 */ + @Update(""" + UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items}, + pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT', + review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending}, + record_version=record_version+1,updated_at=#{at} + WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING' + AND picked_package_count=0 AND shipped_package_count=0 + """) + int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash, + @Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages, + @Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at); + + /** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */ + @Update(""" + UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion}, + review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash}, + dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson}, + cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, + estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, + loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, + arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, + delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount}, + shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt} + WHERE id=#{r.id} AND record_version=#{version} + """) + int saveState(@Param("r") DispatchAggregate row,@Param("version") long originalVersion); + + /** 动作及完整当时主单快照追加保存,不覆盖旧审核轮次或原请求历史。 */ + @Insert("INSERT INTO ll_dispatch_history(id,dispatch_id,record_version,action,operator_id,reason,snapshot_json,occurred_at) " + + "VALUES(#{id},#{dispatch},#{version},#{action},#{operator},#{reason},#{snapshot},#{at})") + int history(@Param("id") long id, @Param("dispatch") long dispatchId, @Param("version") long version, + @Param("action") String action, @Param("operator") String operator, @Param("reason") String reason, + @Param("snapshot") String snapshot, @Param("at") java.time.LocalDateTime at); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index 254dbdded..a150fdfd0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -1,15 +1,11 @@ package com.baiyue.lastleg.dispatch.mapper; - -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - +import com.baiyue.lastleg.dispatch.model.DispatchBolData; +import org.apache.ibatis.annotations.*; import java.util.List; - +/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ @Mapper public interface DispatchBolMapper { - @Select("SELECT " + + @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + "COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," + @@ -24,12 +20,11 @@ public interface DispatchBolMapper { "FROM ll_dispatch_item di " + "LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " + "LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " + - "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " + "LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " + "LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " + - "WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " + - "GROUP BY di.id,l.id ORDER BY di.id,l.id") - List findByDispatchOrder(@Param("customerId") String customerId, - @Param("dispatchOrderId") Long dispatchOrderId); + "WHERE di.dispatch_order_id=#{dispatchOrderId} " + + "GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id") + List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); } 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 index 52151dd76..7de2bcc20 100644 --- 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 @@ -40,7 +40,7 @@ public interface DispatchCandidateMapper { "") List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); - @Select("") + "ORDER BY p.plan_id,p.sequence_no"; + @Select("") List available(@Param("customerId") String customerId, @Param("planIds") List planIds, @Param("orderNo") String orderNo, @@ -143,7 +142,13 @@ public interface DispatchCandidateMapper { @Param("sourceStage") String sourceStage, @Param("draftToken") String draftToken); - @Select("") + List wholeAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("planIds") List planIds,@Param("orderNo") String orderNo, + @Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken); + + String SELECTED_PLAN_SQL = "") + "ORDER BY p.id"; + + /** 先普通读确定真实来源,再按来源订单→库存顺序取得写锁。 */ + @Select(SELECTED_PLAN_SQL + "") + List readSelected(@Param("customerId") String customerId, + @Param("planPalletIds") List planPalletIds); + + /** 已有调用需要的明确锁定读取,投影与普通读保持同一事实。 */ + @Select(SELECTED_PLAN_SQL + " FOR UPDATE") List lockSelected(@Param("customerId") String customerId, @Param("planPalletIds") List planPalletIds); - @Select("") + "GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no"; + @Select("") List relabelAvailable(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); - @Select("") + List wholeRelabelAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("relabelNo") String relabelNo); + + String NATIVE_RELABEL_BODY = "SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," + "MIN(ri.source_warehouse_name) AS overseasWarehouseName,'RETURN_RELABEL' AS orderType,a.relabel_no AS orderNo,a.relabel_no AS orderRefs," + "GROUP_CONCAT(DISTINCT c.source_return_no ORDER BY c.source_return_no SEPARATOR ',') AS inboundCode," + "c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," + @@ -277,11 +294,17 @@ public interface DispatchCandidateMapper { "AND a.relabel_no=#{relabelNo} " + "AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') " + "AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " + - "AND d.customer_id=di.customer_id WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " + - "GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code") + "WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " + + "GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code"; + @Select("") List nativeReturnRelabelAvailable(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); + /** 原生退货换标保持原账本,仓过滤与行数限制在数据库执行。 */ + @Select("") + List wholeNativeRelabelAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("relabelNo") String relabelNo); + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'") int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java new file mode 100644 index 000000000..82dfe424f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 持久命令账本只新增和读取,不更新或覆盖原结果。 */ +@Mapper +public interface DispatchCommandMapper { + /** 全局读取请求键,操作者在服务层判断,不能按客户过滤后重复创建。 */ + @Select("SELECT id,dispatch_id AS dispatchId,dispatch_no AS dispatchNo,action,operator_id AS operatorId," + + "idempotency_key AS idempotencyKey,record_version AS recordVersion,command_hash AS commandHash," + + "result_json AS resultJson,created_at AS createdAt FROM ll_dispatch_command WHERE idempotency_key=#{key}") + DispatchCommandRecord find(@Param("key") String key); + + /** 唯一索引仲裁全局请求键并发,与主单变化在调用方同一事务。 */ + @Insert("INSERT INTO ll_dispatch_command(id,dispatch_id,dispatch_no,action,operator_id,idempotency_key," + + "record_version,command_hash,result_json,created_at) VALUES(#{row.id},#{row.dispatchId},#{row.dispatchNo}," + + "#{row.action},#{row.operatorId},#{row.idempotencyKey},#{row.recordVersion},#{row.commandHash}," + + "#{row.resultJson},#{row.createdAt})") + int insert(@Param("row") DispatchCommandRecord row); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java new file mode 100644 index 000000000..e4342a232 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 复用正式来源/实托唯一占用,仅写本拥有者草稿记录,不修改正式排车占用。 */ +@Mapper +public interface DispatchDraftClaimMapper { + record Claim(Long id,String customerId,String sourceKey,Long palletId,Long containerId,LocalDateTime expiresAt,Long physicalLockId) { } + + /** 来源已加锁后才读取本草稿活动占用;不跨token领取他人记录。 */ + @Select("SELECT id,customer_id AS customerId,source_pallet_key AS sourceKey,warehouse_pallet_id AS palletId,draft_container_id AS containerId,expires_at AS expiresAt,physical_lock_id AS physicalLockId " + + "FROM ll_dispatch_pallet_lock WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL " + + "AND status='ACTIVE' AND expires_at>#{at} ORDER BY id FOR UPDATE") + List owned(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at); + + /** 每个逻辑来源独立保留客户和来源键,同容器同实托共享已取得的物理占用。 */ + @Insert("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id,warehouse_pallet_id," + + "return_receipt_id,dispatch_order_id,draft_container_id,draft_token,operator_id,status,locked_at,expires_at,created_at,updated_at) " + + "VALUES(#{id},#{physical},#{customer},#{key},#{key},#{plan},#{pallet},#{receipt},NULL,#{container},#{token},#{owner},'ACTIVE',#{at},#{expires},#{at},#{at})") + int insert(@Param("id")long id,@Param("customer")String customer,@Param("key")String key,@Param("plan")Long plan, + @Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("container")long container,@Param("token")String token, + @Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires,@Param("physical")Long physical); + + /** 布局移动仅匹配同草稿原容器,不能迁移他人新取得的占用。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token} " + + "AND operator_id=#{owner} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}") + int move(@Param("id")long id,@Param("token")String token,@Param("owner")String owner,@Param("previous")long previous, + @Param("next")long next,@Param("at")LocalDateTime at); + + /** 按精确原身份释放逻辑来源;物理占用由最后成员结束后经共同提供者释放。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} " + + "WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL AND status='ACTIVE' " + + "AND (#{key} IS NULL OR source_pallet_key=#{key}) AND (#{container} IS NULL OR draft_container_id=#{container})") + int release(@Param("token")String token,@Param("owner")String owner,@Param("key")String key,@Param("container")Long container,@Param("at")LocalDateTime at); + + /** 草稿写入续期只延长原草稿活动占用,不复活已被释放或转换的记录。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} AND operator_id=#{owner} " + + "AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}") + int renew(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java new file mode 100644 index 000000000..aaad872bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchDraftCommand; +import org.apache.ibatis.annotations.*; + +/** 唯一命令键串行化并保存全载荷结果;未完成记录绝不单独提交。 */ +@Mapper +public interface DispatchDraftCommandMapper { + /** 同键等待原事务,保留首次主体/动作/请求摘要。 */ + @Insert("INSERT INTO ll_dispatch_draft_command(id,idempotency_key,operator_id,action,target_token,request_hash,created_at) " + + "VALUES(#{id},#{idempotencyKey},#{operatorId},#{action},#{targetToken},#{requestHash},#{createdAt}) ON DUPLICATE KEY UPDATE id=id") + int reserve(DispatchDraftCommand command); + + /** 唯一键竞争后采用当前读恢复提交结果,避免旧快照漏见原命令。 */ + @Select("SELECT * FROM ll_dispatch_draft_command WHERE idempotency_key=#{key} FOR UPDATE") + DispatchDraftCommand lock(@Param("key")String key); + + /** 仅完成本事务首次预占的命令,失败必须使所有业务写入回滚。 */ + @Update("UPDATE ll_dispatch_draft_command SET result_json=#{json} WHERE id=#{id} AND result_json IS NULL") + int complete(@Param("id")long id,@Param("json")String json); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java index 43b428aaf..939bafbb9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java @@ -5,10 +5,27 @@ import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; 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 org.apache.ibatis.annotations.Delete; +import java.time.LocalDateTime; import java.util.List; @Mapper public interface DispatchDraftContainerMapper extends BaseMapper { + /** 容器只在其草稿根锁内修改,普通读保持服务端顺序。 */ @Select("SELECT * FROM ll_dispatch_draft_container WHERE draft_token=#{draftToken} ORDER BY sequence_no,id") List findByToken(@Param("draftToken") String draftToken); + + /** 保存已由服务校验的布局,不从任意客户端JSON新增来源。 */ + @Update("UPDATE ll_dispatch_draft_container SET sequence_no=#{sequence},capacity=#{capacity},source_keys_json=#{keys},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token}") + int layout(@Param("id")long id,@Param("token")String token,@Param("sequence")int sequence,@Param("capacity")int capacity, + @Param("keys")String keys,@Param("at")LocalDateTime at); + + /** 在同一草稿根锁中暂存原序号,交换两容器时不短暂冲撞唯一序号。 */ + @Update("UPDATE ll_dispatch_draft_container SET sequence_no=-sequence_no WHERE draft_token=#{token} AND sequence_no>0") + int stageSequences(@Param("token")String token); + + /** 服务已校验为空的容器才可删除,不释放其他容器的来源。 */ + @Delete("DELETE FROM ll_dispatch_draft_container WHERE id=#{id} AND draft_token=#{token}") + int remove(@Param("id")long id,@Param("token")String token); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java index 210f8eca6..65099672e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java @@ -5,20 +5,34 @@ import com.baiyue.lastleg.dispatch.entity.DispatchDraft; 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 DispatchDraftMapper extends BaseMapper { - @Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " + - "AND (d.expires_at IS NULL OR d.expires_at>CURRENT_TIMESTAMP(3)) AND NOT EXISTS (" + - "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_outbound_order o ON o.customer_id=l.customer_id " + - "AND o.dispatch_order_id=l.dispatch_order_id WHERE l.draft_token=d.draft_token) AND NOT EXISTS (" + - "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order x ON x.id=l.dispatch_order_id " + - "AND x.customer_id=l.customer_id WHERE l.draft_token=d.draft_token AND x.status!='PENDING_DEPARTURE') " + - "ORDER BY d.updated_at DESC") - List findActiveByOperator(@Param("operatorId") String operatorId); + /** 草稿头只按拥有者隔离,不再依赖客户子排车或旧主单状态。 */ + default List findActiveByOperator(String operatorId) { + return findActiveAt(operatorId,LocalDateTime.now(java.time.ZoneOffset.UTC)); + } - @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE") + /** 显式传入UTC时间,数据库会话时区不能使有效草稿被提前视为过期。 */ + @Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " + + "AND d.expires_at>#{at} ORDER BY d.updated_at DESC,d.id") + List findActiveAt(@Param("operatorId")String operatorId,@Param("at")LocalDateTime at); + + /** 保留现有提供者的精确拥有者锁查询,过期资格由新服务统一核对。 */ + @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' FOR UPDATE") DispatchDraft lockActive(@Param("draftToken") String draftToken, @Param("operatorId") String operatorId); + + /** 锁定任何生命周期的原草稿,重放在服务层先于有效期校验。 */ + @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{token} AND operator_id=#{owner} FOR UPDATE") + DispatchDraft lockOwned(@Param("token") String token,@Param("owner") String owner); + + /** 唯一公共版本CAS;所有布局、状态和命令结果随同事务提交。 */ + @Update("UPDATE ll_dispatch_draft SET record_version=#{r.recordVersion},sources_json=#{r.sourcesJson}," + + "plan_ids_json=#{r.planIdsJson},status=#{r.status},expires_at=#{r.expiresAt},released_at=#{r.releasedAt},updated_at=#{r.updatedAt} " + + "WHERE id=#{r.id} AND operator_id=#{r.operatorId} AND record_version=#{version}") + int save(@Param("r") DispatchDraft row,@Param("version") long version); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java new file mode 100644 index 000000000..2d24562b1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 本车真实扫码与不可变归属的有界批量读取,不用计划箱数补造执行。 */ +@Mapper +public interface DispatchExecutionReadMapper { + record RescanCandidate(String dispatchNo,String deliveryNo,long deliveryVersion) { } + record PickSource(Long pickId,Long attributionId,Long itemId,String customerId,Long orderId,String orderNo, + String sourceRegion,String sourceInboundId,String inboundCode,Long positionId,Long receiptId,Long returnReceiptId, + Long returnCartonId,String cartonMark,long packages,long productQuantity,Long dispatchItemSourceId) { } + record ItemPicked(Long itemId,long packages) { } + record ActualMeasurement(Long palletId,String sourceNo,LocalDateTime observedAt,BigDecimal weightKg,BigDecimal volumeCbm) { } + /** 只取实际存在的订单与归属;缺失源头会使完整性核对失败。 */ + @Select(""" + SELECT p.id AS pickId,a.id AS attributionId,i.id AS itemId,a.customer_id AS customerId,a.order_id AS orderId, + o.order_no AS orderNo,o.source_region AS sourceRegion,o.source_inbound_id AS sourceInboundId, + COALESCE(s.source_inbound_code,r.inbound_code,rb.inbound_code,i.inbound_code) AS inboundCode,p.inventory_position_id AS positionId, + a.receipt_id AS receiptId,a.return_receipt_id AS returnReceiptId,a.return_carton_id AS returnCartonId, + c.carton_mark AS cartonMark,a.packages,COALESCE(a.product_quantity,0) AS productQuantity,a.dispatch_item_source_id AS dispatchItemSourceId + FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id + JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=p.customer_id + JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id + LEFT JOIN ll_inbound_stock_receipt r ON r.id=a.receipt_id AND r.customer_id=a.customer_id + LEFT JOIN ll_return_stock_receipt rr ON rr.id=a.return_receipt_id AND rr.customer_id=a.customer_id + LEFT JOIN ll_inventory_balance rb ON rb.id=rr.inventory_balance_id AND rb.customer_id=rr.customer_id + LEFT JOIN ll_return_inventory_carton c ON c.id=a.return_carton_id AND c.customer_id=a.customer_id + LEFT JOIN ll_dispatch_item_source s ON s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id AND s.dispatch_item_id=i.id + WHERE i.dispatch_order_id=#{id} ORDER BY i.id,p.id,a.id + """) + List sources(@Param("id")long dispatchId); + /** 每个明细都必须独立核平,不能以整车合计抵消超拣与漏拣。 */ + @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{id} GROUP BY i.id ORDER BY i.id") + List itemPicked(@Param("id")long dispatchId); + /** 两端共同校验四种来源和逐贡献额度,避免同托来源互相补数量而放行。 */ + String INVALID_PICK=""" + (p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages + OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0) + OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id + AND NOT EXISTS(SELECT 1 FROM ll_order o WHERE o.id=a.order_id AND o.customer_id=a.customer_id)) + OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item_source s WHERE s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id + AND s.dispatch_order_id=i.dispatch_order_id AND s.dispatch_item_id=i.id AND s.inventory_position_id=p.inventory_position_id + AND s.warehouse_pallet_id=i.warehouse_pallet_id AND i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER' + AND (SELECT COALESCE(SUM(x.packages),0) FROM ll_dispatch_pda_pick_order x WHERE x.dispatch_item_source_id=s.id AND x.customer_id=s.customer_id)<=s.package_count)) + OR (i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER' AND EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a + WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NULL))) + """; + /** 每条扫码的不可变归属箱数与商品数必须完整,缺归属不能因总数相等而放行。 */ + @Select(""" + SELECT COUNT(*) FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id + WHERE i.dispatch_order_id=#{id} AND + """+INVALID_PICK) + long inconsistentPicks(@Param("id")long dispatchId); + /** 已完成实测只有当前整托组成未变时才可自动带入,计费重与订单分摊重不在此表。 */ + @Select(""" + SELECT DISTINCT p.warehouse_pallet_id AS palletId,t.transfer_no AS sourceNo,p.completed_at AS observedAt, + p.gross_weight_kg AS weightKg,p.volume_cbm AS volumeCbm + FROM ll_dispatch_item i JOIN ll_warehouse_transfer_pallet p ON p.warehouse_pallet_id=i.warehouse_pallet_id AND p.customer_id=i.customer_id + JOIN ll_warehouse_transfer_order t ON t.id=p.transfer_order_id AND t.customer_id=p.customer_id + WHERE i.dispatch_order_id=#{id} AND p.status='COMPLETED' AND p.carton_count=(SELECT SUM(x.package_count) FROM ll_dispatch_item x + WHERE x.dispatch_order_id=i.dispatch_order_id AND x.warehouse_pallet_id=i.warehouse_pallet_id) AND p.completed_at IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger l WHERE l.customer_id=i.customer_id AND l.pallet_id=p.warehouse_pallet_id + AND l.created_at>=p.completed_at AND l.business_type IN('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND')) + ORDER BY p.warehouse_pallet_id,p.completed_at + """) + List actualMeasurements(@Param("id")long dispatchId); + + /** 有界修复性重扫只找当前真实签收且缺本版本信号的在途车,不锁任何子单。 */ + @Select(""" + SELECT d.dispatch_no AS dispatchNo,l.delivery_no AS deliveryNo,l.version AS deliveryVersion + FROM ll_dispatch_order d JOIN ll_delivery_order l ON l.dispatch_order_id=d.id + WHERE d.status='ACTIVE' AND d.stage='DELIVERING' AND l.signed_packages>0 AND l.signed_at IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM ll_delivery_facts_changed s WHERE s.dispatch_id=d.id AND s.delivery_id=l.id AND s.delivery_version=l.version) + ORDER BY d.updated_at,d.id,l.id LIMIT #{limit} + """) + List rescan(@Param("limit")int limit); +} 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 index 8f94757bf..77a0990d2 100644 --- 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 @@ -11,6 +11,9 @@ import java.util.List; @Mapper public interface DispatchItemMapper extends BaseMapper { + /** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */ + @Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id") + List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); @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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java new file mode 100644 index 000000000..fd6dce681 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItemSource; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 不可变来源快照只追加或随零扫码移货删除,不用聚合库存回推历史贡献。 */ +@Mapper +public interface DispatchItemSourceMapper extends BaseMapper { + record PickedSource(Long sourceId,long packages,long productQuantity) { } + /** 读取整车完整贡献,客户和逻辑明细身份始终随行保留。 */ + @Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} ORDER BY customer_id,dispatch_item_id,inventory_position_id,relabel_item_id,id") + List all(@Param("dispatch")long dispatch); + + /** PDA列表一次读取所有本批主单贡献,不逐车逐来源查询。 */ + @Select("") + List batchAll(@Param("ids")List ids); + + /** 一次核对冻结贡献的实际已消费箱件,不用同position其他申请的拣货抵消额度。 */ + @Select("") + List pickedAll(@Param("ids")List ids); + + /** 扫码位置只能消费所选真实客户和逻辑明细的贡献。 */ + @Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} AND customer_id=#{customer} " + + "AND inventory_position_id=#{position} ORDER BY relabel_item_id,id") + List position(@Param("dispatch")long dispatch,@Param("item")long item, + @Param("customer")String customer,@Param("position")long position); + + /** 额度按不可变真实拣货归属累计,不能以整仓位已拣数代替本来源已拣数。 */ + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customer} AND dispatch_item_source_id=#{source}") + long picked(@Param("customer")String customer,@Param("source")long source); + + /** 零扫码移货才允许删除;已有实际归属时由SQL再次阻断。 */ + @Delete("DELETE FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} " + + "AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.dispatch_item_source_id=ll_dispatch_item_source.id)") + int removeUnpicked(@Param("dispatch")long dispatch,@Param("item")long item); +} 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 deleted file mode 100644 index 97250cbc5..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -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 dispatch_no=#{dispatchNo} LIMIT 1") - DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo); - - @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE") - DispatchOrder lockGlobalByDispatchNo(@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 index ddb022d7e..bfc0c463f 100644 --- 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 @@ -23,13 +23,6 @@ public interface DispatchPalletLockMapper extends BaseMapper List lockActiveByDraftContainer(@Param("draftToken") String draftToken, @Param("draftContainerId") Long draftContainerId); - @Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " + - "ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id " + - "WHERE l.draft_token=#{draftToken} AND l.draft_container_id=#{draftContainerId} AND l.status='ACTIVE' " + - "ORDER BY l.updated_at DESC,l.id DESC LIMIT 1") - Long findDispatchOrderIdByDraftContainer(@Param("draftToken") String draftToken, - @Param("draftContainerId") Long draftContainerId); - @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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java new file mode 100644 index 000000000..7878205ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 物理层只保存实托与真正拥有者,逻辑来源份额仍由各自客户成员保存。 */ +@Mapper +public interface DispatchPhysicalClaimMapper extends BaseMapper { + /** 到期但尚未清键的原行也必须先锁定,不能越过原拥有者领取。 */ + @Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=#{pallet} FOR UPDATE") + DispatchPhysicalPalletLock lockActive(@Param("pallet")long pallet); + + /** 释放按原物理占用主键取得,不能按实托号误释放新拥有者的后续占用。 */ + @Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE id=#{id} FOR UPDATE") + DispatchPhysicalPalletLock lockOriginal(@Param("id")long id); + + /** 只读原实托身份供服务排序,再逐主键取锁,避免filesort之前按主键先锁的执行计划。 */ + @Select("") + List originals(@Param("ids")List ids); + + /** 批量释放、转换与续约前按物理托盘顺序收齐本主单的原行。 */ + @Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE dispatch_order_id=#{dispatch} AND status='ACTIVE' ORDER BY warehouse_pallet_id,id") + List lockDispatch(@Param("dispatch")long dispatch); + + /** 草稿归属同时包含token与操作人,容器身份由每条原行保留。 */ + @Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE draft_token=#{token} AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' ORDER BY warehouse_pallet_id,id") + List lockDraft(@Param("token")String token,@Param("operator")String operator); + + /** 仅使该原物理占用下已到期的草稿成员失效,不释放正式来源或已经续期的成员。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,released_at=#{at},updated_at=#{at} " + + "WHERE physical_lock_id=#{id} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} " + + "AND EXISTS(SELECT 1 FROM ll_dispatch_physical_pallet_lock p WHERE p.id=#{id} AND p.status='ACTIVE' AND p.dispatch_order_id IS NULL " + + "AND p.draft_token=ll_dispatch_pallet_lock.draft_token AND p.draft_container_id=ll_dispatch_pallet_lock.draft_container_id " + + "AND p.operator_id=ll_dispatch_pallet_lock.operator_id AND p.expires_at<=#{at})") + int expireMembers(@Param("id")long id,@Param("at")LocalDateTime at); + + /** 最后活动成员结束后才能清物理键;原id保证不会触碰同托后续的新占用。 */ + @Update("UPDATE ll_dispatch_physical_pallet_lock SET status=#{status},active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} " + + "WHERE id=#{id} AND status='ACTIVE' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} AND l.status='ACTIVE')") + int releaseUnused(@Param("id")long id,@Param("status")String status,@Param("at")LocalDateTime at); + + /** 草稿成员已经在同事务全部移入目标容器后,才允许迁移物理拥有者。 */ + @Update("UPDATE ll_dispatch_physical_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} " + + "AND draft_token=#{token} AND operator_id=#{operator} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL " + + "AND status='ACTIVE' AND expires_at>#{at} AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} " + + "AND l.status='ACTIVE' AND (l.dispatch_order_id IS NOT NULL OR l.draft_token IS NULL OR l.draft_token<>#{token} " + + "OR l.draft_container_id IS NULL OR l.draft_container_id<>#{next} OR l.operator_id IS NULL OR l.operator_id<>#{operator}))") + int moveDraft(@Param("id")long id,@Param("token")String token,@Param("operator")String operator, + @Param("previous")long previous,@Param("next")long next,@Param("at")LocalDateTime at); + + /** 活动续约不复活已到期记录,期限与逻辑来源及草稿根一起提交。 */ + @Update("UPDATE ll_dispatch_physical_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} " + + "AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}") + int renewDraft(@Param("token")String token,@Param("operator")String operator,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java new file mode 100644 index 000000000..200b18f78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.OutboundReference; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */ +@Mapper +public interface DispatchReadMapper { + /** 即使计数漂移也不得把已有真实扫码当成零扫码。 */ + @Select("SELECT COALESCE(SUM(p.packages),0) FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{id}") + long realPicked(@Param("id") long dispatchId); + record DeliveryRow(String groupKey, String customerId, String customerName, String outboundNo, + String deliveryNo, String type, String warehouseId, String warehouseCode, String warehouseName, + String address, String postalCode, String contactName, String contactPhone, String status, + long packageCount, long palletCount) { } + record BatchRow(Long dispatchItemId,Long planPalletId,String batchKey, String batchNo, String customerId, String sourceKey, String destinationKey, + int packageCount, String confirmationNo, String status, String warehouseCode, LocalDateTime startUtc, Long recordVersion) { } + + /** 读取所有客户出库,不因客户筛选把整车单据缩减成一张。 */ + @Select(""" + SELECT o.customer_id AS customerId,c.customerName,o.outbound_no AS outboundNo,o.status, + o.package_count AS packageCount,o.pallet_count AS palletCount + FROM ll_outbound_order o LEFT JOIN(SELECT dispatch_order_id,customer_id,MAX(customer_name) AS customerName + FROM ll_dispatch_item GROUP BY dispatch_order_id,customer_id)c + ON c.dispatch_order_id=o.dispatch_order_id AND c.customer_id=o.customer_id + WHERE o.dispatch_order_id=#{id} ORDER BY o.customer_id,o.id + """) + List outbounds(@Param("id") long dispatchId); + + /** 所有目的地单据一次读取,地址字段只来自真实派送记录。 */ + @Select(""" + SELECT delivery_group_key AS groupKey,customer_id AS customerId,customer_name AS customerName, + outbound_no AS outboundNo,delivery_no AS deliveryNo,destination_type AS type, + destination_warehouse_id AS warehouseId,destination_warehouse_code AS warehouseCode, + destination_warehouse_name AS warehouseName,delivery_address AS address,postal_code AS postalCode, + contact_name AS contactName,contact_phone AS contactPhone,status,package_count AS packageCount,pallet_count AS palletCount + FROM ll_delivery_order WHERE dispatch_order_id=#{id} ORDER BY customer_id,id + """) + List deliveries(@Param("id") long dispatchId); + + /** 关联按真实明细客户读取,UTC时间不从当地时间猜测。 */ + @Select(""" + SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,CASE WHEN p.id IS NULL THEN d.dispatch_no ELSE p.appointment_no END AS batchNo, + a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey, + a.package_count AS packageCount,p.confirmation_no AS confirmationNo,p.appointment_status AS status, + p.fba_warehouse_code AS warehouseCode,p.appointment_start_utc AS startUtc, + CASE WHEN a.batch_key LIKE 'FBA_APPOINTMENT:%' THEN p.version + WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.record_version ELSE NULL END AS recordVersion + FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id + JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id + LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) + WHERE d.id=#{id} ORDER BY a.batch_key,a.customer_id,a.source_key,a.destination_key,a.dispatch_item_id + """) + List batches(@Param("id") long dispatchId); + + /** 未接通跨客户批次写提供者前必须阻断静默丢批次,不能写无依据运输。 */ + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id + JOIN ll_inbound_fba_appointment_item f ON f.order_id=l.order_id AND f.customer_id=l.customer_id AND f.active=1 + WHERE a.pallet_id=#{pallet} AND l.customer_id=#{customer} + """) + long activeAppointments(@Param("customer") String customer, @Param("pallet") Long planPalletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java new file mode 100644 index 000000000..9d23d70ec --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 排车选货的来源身份、真实库存版本及排他资源锁。 */ +@Mapper +public interface DispatchSourceMapper { + record Stock(long id, long balanceId, Long palletId, String warehouseId, long availablePackages, long holdPackages, + long allocatedPackages, long pickedPackages, long version) { } + record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { } + record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { } + record Claim(Long id, Long dispatchId, String draftToken) { } + record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { } + record NativeApplication(String customerId,String relabelNo) { } + record PlanReference(Long planPalletId,String customerId) { } + record ReturnReference(Long receiptId,String customerId) { } + record OrderReference(Long orderId,String customerId) { } + record ItemPicked(Long itemId,long packages) { } + + /** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */ + @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p " + + "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List pickedItems(@Param("dispatch")long dispatch); + + /** 组成查询不按占用可选性筛掉本草稿或隐藏客户的真实成员。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List ids); + + /** 退回实收的事实身份不因当前草稿占用而消失。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List physicalReturns(@Param("warehouse")String warehouse,@Param("ids")List ids); + + /** 当前余额对应的真实实收订单参加共同锁序,不拿展示MIN或原货源快照ID猜订单表身份。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List physicalOrders(@Param("warehouse")String warehouse,@Param("ids")List ids); + + /** 共同串行只补齐真实身份锁,不给额外原货源或仓内操作新增暂扣资格。 */ + @Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} FOR UPDATE") + Long lockOrderIdentity(@Param("customer")String customer,@Param("id")long id); + + /** 原生退货实托仍由真实箱唛与申请号关联,不借普通库存仓位推断。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List nativeApplications(@Param("warehouse")String warehouse,@Param("codes")List codes); + + /** 换标单客户由服务端业务单号查得,不接受客户端指定。 */ + @Select("SELECT customer_id FROM ll_relabel_application WHERE relabel_no=#{number}") + String relabelOwner(@Param("number") String number); + + /** 无独立版本字段的申请以真实更新时间和客户确认轮次参加来源摘要。 */ + @Select("SELECT status,fulfillment_status AS fulfillmentStatus,updated_at AS updatedAt,customer_confirmed_at AS customerConfirmedAt " + + "FROM ll_relabel_application WHERE relabel_no=#{number}") + RelabelBasis relabelBasis(@Param("number") String number); + + /** 同托真实收货和计划组成共同定位来源订单,禁止按展示MIN订单猜归属。 */ + @Select(""" + SELECT DISTINCT x.order_id FROM ( + SELECT r.order_id FROM ll_inbound_stock_receipt r WHERE r.customer_id=#{customer} AND r.warehouse_pallet_id=#{pallet} + UNION SELECT r.order_id FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} + UNION SELECT l.order_id FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=#{customer} + WHERE a.pallet_id=#{planPallet} + ) x ORDER BY x.order_id + """) + List sourceOrders(@Param("customer") String customer, @Param("pallet") Long pallet, + @Param("planPallet") Long planPallet); + + /** 锁来源订单先于库存,并在同一锁内拒绝暂扣。 */ + @Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} AND detained=0 FOR UPDATE") + Long lockOrder(@Param("customer") String customer, @Param("id") Long id); + + /** 计划托盘尚无实物时也有可串行占用的真实来源根。 */ + @Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE") + Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id); + + /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ + @Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE") + Long lockReturn(@Param("customer") String customer, @Param("id") Long id); + + /** sourceVersion包含真实库存数量和版本;写时按仓位ID稳定加锁。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List stock(@Param("customer") String customer, @Param("pallet") Long pallet, @Param("lock") boolean lock); + + /** 版本还包含逐入仓真实组成,后到货不得静默加入原选货。 */ + @Select("SELECT id,order_id AS orderId,inventory_position_id AS inventoryPositionId,received_packages AS packages,inbound_code AS inboundCode " + + "FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id=#{pallet} ORDER BY id") + List receipts(@Param("customer") String customer, @Param("pallet") Long pallet); + + /** 同托真实退回实收也参加来源摘要,不能被原计划展示订单覆盖。 */ + @Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id") + List returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet); + + /** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} " + + "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}") + int expire(@Param("customer") String customer,@Param("key") String key,@Param("at") LocalDateTime at); + + /** 逻辑键只代表本客户的这条来源,不能因同托已有其他来源就跳过插入。 */ + @Select("SELECT id,dispatch_order_id AS dispatchId,draft_token AS draftToken FROM ll_dispatch_pallet_lock " + + "WHERE status='ACTIVE' AND customer_id=#{customer} AND active_key=#{key} " + + "AND (expires_at IS NULL OR expires_at>#{at}) ORDER BY id FOR UPDATE") + List claims(@Param("customer") String customer,@Param("key") String key,@Param("at") LocalDateTime at); + + /** 精确释放本车本客户完整来源键,不能按共享实托一起释放另一申请。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND source_pallet_key=#{key} AND status='ACTIVE'") + int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key,@Param("at")LocalDateTime at); + + /** 原计划收货只补齐本逻辑成员的实际实托和已取得的物理占用引用。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},physical_lock_id=#{physical},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL AND physical_lock_id IS NULL") + int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("physical")long physical,@Param("at")LocalDateTime at); + + /** 本来源活动唯一键独立保存,实际实托必须关联本拥有者的物理占用。 */ + @Insert(""" + INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id, + warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at) + VALUES(#{id},#{physical},#{customer},#{key},#{key},#{planPallet},#{pallet},#{receipt},#{dispatch},'ACTIVE',#{operator},#{at},#{at},#{at}) + """) + int claim(@Param("id")long id,@Param("customer")String customer,@Param("key")String key, + @Param("planPallet")Long planPallet,@Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("physical")Long physical, + @Param("dispatch")long dispatch,@Param("operator")String operator,@Param("at")LocalDateTime at); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java index 74bb166d7..82e8e75d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java @@ -23,17 +23,28 @@ public interface ReturnDispatchCandidateMapper { "JOIN ll_delivery_order d ON d.customer_id=r.customer_id AND d.id=e.delivery_order_id "; String ELIGIBLE = " AND o.detained=0 AND p.available_packages>0 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.return_receipt_id=r.id) "; - @Select("") + "ORDER BY r.received_at,r.id"; + @Select("") List available(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("fba") String fba, @Param("draftToken") String draftToken); + /** 退回候选按真实库存仓过滤,保持多客户读取。 */ + @Select("") + List wholeAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("fba") String fba,@Param("draftToken") String draftToken); + + /** 只读解析选定退回来源,避免在来源订单前抢占库存锁。 */ + @Select("") + List readSelected(@Param("customerId") String customerId, @Param("ids") List ids); + @Select("") List lockSelected(@Param("customerId") String customerId, @Param("ids") List ids); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java new file mode 100644 index 000000000..869c23a97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dispatch.model; +import java.time.LocalDateTime; +import java.util.*; +/** 打印服务内部一致快照,不恢复旧排车JSON协议。 */ +public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName, + String vehiclePlate,LocalDateTime estimatedArrivalAt,List bolLines,Map appointmentNumbers) { + public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo, + String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId, + String poReferenceNo,int packageCount) { + public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);} + public String destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;} + public String groupKey(){return customerId+"\u0000"+destinationKey();} + } + public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index 397f162e3..defc08736 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -2,12 +2,9 @@ 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 com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; -import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; -import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.*; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; @@ -20,52 +17,56 @@ import java.util.List; @RequestMapping("/api/pda/v1/last-leg/dispatches") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class DispatchPdaController { - @Resource private DispatchPdaWorkflowService service; - @Resource private FleetTripExecutionService trips; + @Resource private DispatchPdaQueryService queries; + @Resource private DispatchPickingService picking; + @Resource private DispatchAggregateService workflow; + @Resource private DispatchDepartureService departure; + /** 批量读取当前绑定仓的整车作业。 */ @GetMapping - public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String status) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - String warehouse = String.valueOf(identity.overseasWarehouseId()); - var tasks = service.list(warehouse, status); - var gates = trips.gates(warehouse, tasks.stream().map(DispatchPdaResponses.Task::dispatchNo).toList()); - return ApiResponse.success(tasks.stream().map(task -> task.withGate(gates.get(task.dispatchNo()))).toList()); + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required=false)DispatchTypes.Stage stage) { + return ApiResponse.success(queries.list(DispatchActorFactory.pda(user),stage)); } + /** 使用排车业务号读取当前仓真实货物和扫码进度。 */ @GetMapping("/{dispatchNo}") - public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - String warehouse = String.valueOf(identity.overseasWarehouseId()); - return ApiResponse.success(service.task(warehouse, dispatchNo).withGate(trips.gate(warehouse, dispatchNo))); + return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo)); } + /** 共用公共版本和全局请求键执行一次真实库存扫码。 */ @PostMapping("/{dispatchNo}/items/{itemId}/pick") - public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, @PathVariable long itemId, - @Valid @RequestBody DispatchPdaRequests.Pick request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.pick(String.valueOf(identity.overseasWarehouseId()), dispatchNo, itemId, - identity.operatorKey(), identity.operatorName(), request)); + @Valid @RequestBody DispatchCommands.Pick request) { + return ApiResponse.success(picking.pick(DispatchActorFactory.pda(user),dispatchNo,itemId,request)); } - @PostMapping("/{dispatchNo}/complete") - public ApiResponse completePicking( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @Valid @RequestBody DispatchPdaRequests.CompletePicking request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.completePicking(String.valueOf(identity.overseasWarehouseId()), - dispatchNo, request)); + /** 与平台共用同一实载证据读取。 */ + @GetMapping("/{dispatchNo}/loading") + public ApiResponse loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo)); } + /** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */ + @PostMapping("/{dispatchNo}/loading-complete") + public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { + return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request)); + } + + /** 与平台共用实际发车服务,不执行第二次库存扣减。 */ @PostMapping("/{dispatchNo}/depart") - public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody FleetExecutionModels.Depart request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(trips.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo, - identity.operatorKey(), request)); + @Valid @RequestBody DispatchCommands.Depart request) { + return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request)); + } + + /** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(workflow.command(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey)); } } 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 deleted file mode 100644 index f8591d251..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java +++ /dev/null @@ -1,19 +0,0 @@ -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 Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, - @NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) { - 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) {} -} 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 index 845cc9847..01dcf8530 100644 --- 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 @@ -1,43 +1,6 @@ package com.baiyue.lastleg.dispatch.pda.dto; - -import java.util.List; -import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; - +/** 原生退货换标箱的内部读取投影。 */ public final class DispatchPdaResponses { - private DispatchPdaResponses() {} - public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, - boolean canDepart, String departBlockReason, List items, String tripNo, Long tripRecordVersion, int tripDispatchCount, String driverWarning, - boolean canPick, String pickBlockReason) { - public Task withGate(FleetExecutionModels.Gate gate) { - return new Task(id, taskNo, dispatchNo, vehiclePlate, warehouseId, status, gate.canDepart(), gate.blockReason(), items, - gate.tripNo(), gate.recordVersion(), gate.dispatchCount(), gate.driverWarning(), gate.canPick(), gate.pickBlockReason()); - } - } - public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, - String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, - int scheduledPackages, - long pickedPackages, List positions, List returnCartons) { - public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, - String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, - int scheduledPackages, long pickedPackages, List positions) { - this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, - warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of()); - } - } - public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {} - 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); - } - } + private DispatchPdaResponses() { } + public record ReturnCarton(Long id,String cartonMark,String status,long productQuantity) { } } 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 index 225cb9aa5..95dde7510 100644 --- 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 @@ -17,6 +17,7 @@ public class DispatchPdaPickOrder { private Long receiptId; private Long returnReceiptId; private Long returnCartonId; + private Long dispatchItemSourceId; private Long orderId; private Long packages; private Long productQuantity; 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 index 81ccee623..12b51e7b2 100644 --- 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 @@ -11,7 +11,6 @@ import java.time.LocalDateTime; @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; 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 index 2b8c0f33c..27aa78364 100644 --- 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 @@ -7,6 +7,9 @@ import java.util.List; @Mapper public interface DispatchPdaPickMapper extends BaseMapper { + /** 按真实明细客户读取全车归属,不要求整车主单存在客户列。 */ + @Select("SELECT p.* FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{dispatch} AND p.customer_id=#{customer} ORDER BY p.id") + List wholeDispatch(@Param("customer")String customer,@Param("dispatch")long dispatch); @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}") 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 index 26b78cd40..3099c4da0 100644 --- 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 @@ -18,12 +18,14 @@ public interface DispatchPdaPickOrderMapper extends BaseMapper deliveryPickedSkus(@Param("customerId") String customerId, 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 index c92c6232f..fad6512d2 100644 --- 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 @@ -8,11 +8,11 @@ import java.util.List; @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 dispatch_order_id=#{dispatchOrderId} FOR UPDATE") + DispatchPdaTask lockByDispatch(@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); + @Select("SELECT * FROM ll_dispatch_pda_task WHERE dispatch_order_id=#{dispatchOrderId}") + DispatchPdaTask findByDispatch(@Param("dispatchOrderId") Long dispatchOrderId); @Select("SELECT * FROM ll_dispatch_pda_task WHERE warehouse_id=#{warehouseId} " + "AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java new file mode 100644 index 000000000..47fed9de8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 绑定仓整车PDA批量读取,不读取单客户主单头或逐车查询详情。 */ +@Mapper +public interface DispatchWholePdaReadMapper { + record Picked(Long itemId,long packages) { } + record Invalid(Long dispatchId,long quantity) { } + record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { } + /** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */ + @Select("") + List headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage); + String ROOTS="#{id}"; + /** 一次读取选中整车所有真实客户货物。 */ + @Select("") + List items(@Param("ids")List ids); + /** 实际扫码按真实明细客户合计,不依赖客户任务头的假归属。 */ + @Select("") + List picked(@Param("ids")List ids); + /** 每条扫码的来源订单、箱数与商品数均须真实完整。 */ + @Select("") + List invalid(@Param("ids")List ids); + /** 仓位先按本批明细客户、实托及绑定仓一次读取。 */ + @Select("") + List positions(@Param("ids")List ids,@Param("warehouse")String warehouse); + /** 原生退货箱仍从原账本取真实箱号,精确选中集合由业务层再比对。 */ + @Select("") + List cartons(@Param("ids")List ids,@Param("warehouse")String warehouse); +} 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 index adc8c9907..dbf1f26f3 100644 --- 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 @@ -1,405 +1,42 @@ package com.baiyue.lastleg.dispatch.pda.service; -import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; - -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.delivery.mapper.OutboundPalletItemMapper; -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.transfer.mapper.WarehouseTransferOrderMapper; -import com.baiyue.lastleg.inventory.entity.InventoryPosition; -import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; -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.time.*; import java.util.*; import java.util.stream.Collectors; +/** 实际发车推进既有来源状态;扫码与装车只使用公共整车命令。 */ @Service public class DispatchPdaWorkflowService { - @Resource private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution; + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository wholePicks; + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceDepartureRepository wholeDeparture; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; - @Resource private FleetDispatchBindingGuard fleetBindings; - @Resource private DispatchOrderMapper orders; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private OutboundPalletItemMapper outboundPalletItems; - @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 RelabelApplicationService relabelApplications; - @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private DispatchPickOrderAttributionService pickAttribution; - @Resource private ObjectMapper objectMapper; - @Resource private com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnRelabelStock; - - @Transactional(readOnly = true) - public List list(String warehouseId, String status) { - String boundWarehouseId = warehouse(warehouseId); - List result = new ArrayList<>(); - for (DispatchPdaTask pdaTask : tasks.listByWarehouse(boundWarehouseId, trim(status))) { - DispatchOrder order = orders.selectById(pdaTask.getDispatchOrderId()); - if (order == null || !pdaTask.getCustomerId().equals(order.getCustomerId()) - || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { - throw conflict("Dispatch PDA task scope is inconsistent"); + @Resource private RelabelApplicationService relabelApplications; + /** 全部客户子单锁及来源订单锁已由调用方收齐,此处只推进原来源计数与原业务状态。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void departWholeSources(com.baiyue.lastleg.dispatch.port.DispatchContracts.Context context, + com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor actor, + com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.DepartureResult result,Instant departedAt) { + var at=LocalDateTime.ofInstant(departedAt,java.time.ZoneOffset.UTC); + var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::customerId,TreeMap::new,Collectors.toList())); + for(var group:byCustomer.entrySet()) { + String customer=group.getKey();var outbound=result.outbounds().stream().filter(o->customer.equals(o.customerId())).findFirst().orElseThrow(()->conflict("本客户实际出库单缺失")); + var actual=wholePicks.picks(customer,context.dispatchId()); + if(actual.isEmpty())throw conflict("本客户缺少真实扫码来源"); + pickAttribution.depart(customer,outbound.outboundNo(),context.dispatchNo(),actual,at); + for(var order:group.getValue().stream().filter(c->c.relabelNo()==null).map(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::orderId).distinct().sorted().toList())wholeDeparture.transferOrder(customer,order,at); + for(var no:group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::relabelNo).filter(Objects::nonNull).distinct().sorted().toList()) { + wholeDeparture.relabel(customer,no,at);relabelApplications.changeStatus(customer,no,actor.operatorId(),new RelabelRequests.ChangeStatus("COMPLETED","整车实际发车完成")); } - result.add(task(order.getCustomerId(), order, pdaTask, - items.findByDispatchOrder(order.getCustomerId(), order.getId()))); - } - return result; - } - - @Transactional - public DispatchPdaResponses.Task task(String warehouseId, String dispatchNo) { - DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = order.getCustomerId(); - items.bindReceivedPallets(customerId, order.getId()); - DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId()); - if (task == null) throw notFound("Dispatch PDA task not found"); - requireTaskWarehouse(task, warehouseId); - return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); - } - - @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) - public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, - String operatorId, String operatorName, - DispatchPdaRequests.Pick request) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - // Transfer and picking serialize on the same dispatch before taking task/inventory locks. - DispatchOrder order = orders.lockById(customerId, found.getId()); - if (order == null) throw notFound("Dispatch order not found"); - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - if (task == null) 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"); - DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); - if (replay != null) { - DispatchPdaResponses.Picked result = read(replay, DispatchPdaResponses.Picked.class); - if (!task.getId().equals(replay.getTaskId()) || result.dispatchItemId() != itemId) { - throw conflict("Idempotency key was reused with a different dispatch pick request"); - } - return result; - } - requirePdaStatus(order); - requireVehicle(order); - fleetBindings.requirePickingApproved(order.getId()); - items.bindReceivedPallets(customerId, order.getId()); - if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { - throw conflict("Dispatch PDA task is not available for picking"); - } - bindOperator(task, operatorId, operatorName); - 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"); - boolean nativeReturn = "RETURN_RELABEL".equals(item.getSourceStage()); - if (!nativeReturn && request.positionId() == null) throw conflict("普通入仓拣货必须选择库存仓位"); - if (nativeReturn && request.positionId() != null) throw conflict("退货箱拣货请扫描来源箱唛,不使用普通库存仓位"); - InventoryPosition position = nativeReturn ? null : positions.selectById(request.positionId()); - if (!nativeReturn && (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"); - long pickId = IdWorker.getId(); - Long returnCartonId = null; - if (nativeReturn) { - pickAttribution.prepareNativeReturn(customerId, item.getOrderId()); - returnCartonId = returnRelabelStock.pick(item, request.cartonMark(), request.packages(), request.productQuantity(), - pickId, order.getDispatchNo()).getId(); - } else { - pickAttribution.preparePicking(customerId, request.positionId(), - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); - String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); - InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":ALLOCATE", request.remark()); - if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) { - inventory.release(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), item.getSourceBusinessNo(), order.getDispatchNo(), null, - keyBase + ":RELABEL_RELEASE", "换标排车拣货前释放冻结库存")); - } - inventory.allocate(customerId, operatorId, move); - inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":PICK", request.remark())); - // A successful PDA scan is the inventory deduction point. Departure only - // changes outbound/dispatch facts and must not deduct the same quantity again. - inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":SHIP", request.remark())); - } - DispatchPdaPick pick = new DispatchPdaPick(); - pick.setId(pickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setReturnCartonId(returnCartonId); - 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); - if (nativeReturn) pickAttribution.attributeNativeReturn(customerId, pick, item.getOrderId()); - else pickAttribution.attribute(customerId, pick, - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId()); - 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 warehouseId, String dispatchNo, - DispatchPdaRequests.CompletePicking request) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - 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()); - fleetBindings.requirePickingApproved(order.getId()); - if (task != null && "COMPLETED".equals(task.getStatus())) { - List completedItems = items.findByDispatchOrder(customerId, order.getId()); - long planned = completedItems.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(); - DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted( - order.getDispatchNo(), order.getStatus(), planned, picked, Math.max(0, planned - picked), false, false); - DispatchPdaOperation completed = operations.findCompleted(customerId, task.getId(), "COMPLETE_PICKING"); - if (completed != null) throw conflict("PDA picking was already completed with another idempotency key"); - save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); - return result; - } - 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 (planned <= 0 || picked != planned || !allPicked(customerId, task.getId(), source)) { - throw conflict("Every dispatch pallet and package must be fully picked before completion"); - } - LocalDateTime now = LocalDateTime.now(); - task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task); - order.setStatus("READY_TO_DEPART"); - order.setPartialCompletionReason(null); - order.setPickedPackageCount((int) picked); - order.setUpdatedAt(now); orders.updateById(order); - DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), - order.getStatus(), planned, picked, 0, false, 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(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) - public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId, - DispatchPdaRequests.Depart request, LocalDateTime departedAt) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - 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(); - fleetBindings.requireCoordinatedDeparture(order.getId()); - 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"); - } - deliveryExecution.requireDeparture(outbound); - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - if (task == null || !"COMPLETED".equals(task.getStatus()) - || !"READY_TO_DEPART".equals(order.getStatus())) { - throw conflict("Dispatch must complete PDA picking before departure"); - } - requireTaskWarehouse(task, warehouseId); - - List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); - if (!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 = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); - LocalDateTime now = Objects.requireNonNull(departedAt, "departedAt"); - pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); - if (outboundPalletItems != null) { - for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) { - boolean selected = dispatchItems.stream().anyMatch(item -> Objects.equals(item.getWarehousePalletId(), outboundItem.getWarehousePalletId()) - && Objects.equals(item.getInboundCode(), outboundItem.getInboundCode())); - if (selected) { - outboundItem.setPickedPackageCount(outboundItem.getPackageCount()); - outboundItem.setStatus("PICKED"); - outboundPalletItems.updateById(outboundItem); - } - } - } - for (DispatchItem item : dispatchItems) { - // A relabel dispatch may consume only part of a warehouse-transfer - // pallet. Do not close the whole transfer by its parent order; the - // remaining cartons still need destination binding/outbound. - if (item.getOrderId() != null && !"RELABEL_ORDER".equals(item.getSourceBusinessType())) { - warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); - } - if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) { - warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now); - } - } - dispatchItems.stream().filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) - .map(DispatchItem::getSourceBusinessNo).filter(Objects::nonNull).distinct().forEach(relabelNo -> - relabelApplications.changeStatus(customerId, relabelNo, operatorId, - new RelabelRequests.ChangeStatus("COMPLETED", "换标排车PDA发车完成"))); - outbound.setStatus("OUTBOUNDED"); - outbound.setUpdatedAt(now); - outboundOrders.updateById(outbound); - order.setShippedPackageCount((int) shipped); - order.setStatus("DEPARTED"); - order.setDepartAt(now); - order.setUpdatedAt(now); - orders.updateById(order); - releasePalletLocks(customerId, order.getId(), now); - Map sourcePackages = new TreeMap<>(); - for (var item : dispatchItems) if (item.getOrderId() != null) - sourcePackages.merge(item.getOrderId(), item.getPackageCount(), Math::addExact); - fleetPool.departed(customerId, order.getDispatchNo(), sourcePackages, operatorId, 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) { - if ("RETURN_RELABEL".equals(item.getSourceStage())) { - values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), - item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), - warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), - picks.totalItem(customerId, task.getId(), item.getId()), List.of(), returnRelabelStock.detail(item))); - continue; - } - List palletPositions = item.getWarehousePalletId() == null ? List.of() - : positions.listPallet(customerId, item.getWarehousePalletId()); - boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType()); - values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), - item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), - warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), - picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> - new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()) - + (relabel ? n(p.getHoldPackages()) : 0), - n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); - } - OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId()); - String departBlockReason = departBlockReason(order, task, source, outbound); - return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), - task.getWarehouseId(), "DEPARTED".equals(order.getStatus()) ? "DEPARTED" : task.getStatus(), - departBlockReason == null, departBlockReason, values, null, null, 0, null, false, "请先关联车次并通过调度审核"); - } - - private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List source, - OutboundOrder outbound) { - if ("DEPARTED".equals(order.getStatus())) return "该排车已发车,不可重复提交。"; - if (trim(order.getVehiclePlate()) == null) return "请先在平台维护车辆信息。"; - if (!"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus()) - || !allPicked(order.getCustomerId(), task.getId(), source)) { - return "请先完成排车拣货并核平全部箱数。"; - } - if (outbound == null) return "请先在平台完成“转派送”,生成待出库单后才能发车。"; - if (!"WAIT_OUTBOUND".equals(outbound.getStatus())) return "关联出库单不是待出库状态,暂不可发车。"; - return null; - } - - 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); + Map packages=new TreeMap<>();for(var cargo:group.getValue())packages.merge(cargo.orderId(),Math.toIntExact(cargo.packages()),Math::addExact); + fleetPool.departed(customer,context.dispatchNo(),packages,actor.operatorId(),at); } } - private DispatchOrder requireWarehouseOrder(String warehouseId, String dispatchNo) { - String boundWarehouseId = warehouse(warehouseId); - DispatchOrder order = orders.findGlobalByDispatchNo(required(dispatchNo, "Dispatch number is required")); - if (order == null || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { - throw notFound("Dispatch order not found in the bound warehouse"); - } - 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 requireTaskWarehouse(DispatchPdaTask task, String warehouseId) { - if (!warehouse(warehouseId).equals(trim(task.getWarehouseId()))) { - throw notFound("Dispatch PDA task not found in the bound warehouse"); - } - } - private static void bindOperator(DispatchPdaTask task, String operatorId, String operatorName) { - String normalizedOperator = required(operatorId, "PDA operator is required"); - if (trim(task.getOperatorId()) != null && !normalizedOperator.equals(trim(task.getOperatorId()))) { - throw conflict("Dispatch PDA task is already assigned to another operator"); - } - task.setOperatorId(normalizedOperator); - task.setOperatorName(trim(operatorName)); - } - 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; } - static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) { - String original = trim(originalFbaWarehouseCode); - String destination = trim(destinationWarehouseCode); - return original != null && destination != null && !original.equalsIgnoreCase(destination) - ? original + " -> " + destination : null; - } - 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); } + private static RuntimeException conflict(String message){return new com.baiyue.common.core.exception.BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,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 index 9d1f51a3d..45b9a0687 100644 --- 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 @@ -18,6 +18,9 @@ import java.time.LocalDateTime; import java.util.TreeMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService; @Service public class DispatchPickOrderAttributionService { @@ -25,14 +28,41 @@ public class DispatchPickOrderAttributionService { @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper history; @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; + @Resource private DispatchItemSourceRepository relabelSources; - /** Acquire source order locks before any inventory lock, matching order hold/release. */ + /** 普通换标只锁本申请真实履约单,不读取其他来源receipt做FIFO猜测。 */ + @org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void prepareRelabel(String customerId,Long orderId) { + var order=orderId==null?null:orders.lockById(customerId,orderId); + if(order==null || Boolean.TRUE.equals(order.getDetained()))throw conflict("换标履约订单缺失或暂扣"); + } + + /** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */ + @org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List allocations) { + long packages=0,quantity=0;var seen=new java.util.HashSet(); + for(var allocation:allocations) { + var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId()); + if(source==null || !seen.add(source.getId()) || !Objects.equals(source.getCustomerId(),customerId) + || !Objects.equals(source.getDispatchItemId(),pick.getDispatchItemId()) || !Objects.equals(source.getInventoryPositionId(),pick.getInventoryPositionId()) + || allocation.packages()<=0 || allocation.productQuantity()!=null && allocation.productQuantity()<0)throw conflict("换标扫码冻结贡献身份不一致"); + packages=Math.addExact(packages,allocation.packages());quantity=Math.addExact(quantity,allocation.productQuantity()==null?0:allocation.productQuantity()); + } + if(allocations.isEmpty() || packages!=value(pick.getPackages()) || quantity!=value(pick.getProductQuantity()))throw conflict("换标扫码箱件与真实贡献未核平"); + for(var allocation:allocations) { + var row=new DispatchPdaPickOrder();row.setId(IdWorker.getId());row.setCustomerId(customerId);row.setPickId(pick.getId());row.setOrderId(orderId); + row.setDispatchItemSourceId(allocation.dispatchItemSourceId());row.setPackages(allocation.packages());row.setProductQuantity(allocation.productQuantity()==null?0:allocation.productQuantity());row.setCreatedAt(pick.getPickedAt()); + if(attributions.insert(row)!=1)throw conflict("换标实际来源归属未保存"); + } + if(orderId==null || orders.increasePicked(customerId,orderId,packages)!=1)throw conflict("换标履约实际拣货箱数不一致"); + } + + /** 来源订单先于库存锁,普通实收不补造换标履约归属。 */ @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) - public void preparePicking(String customerId, long positionId, Long fallbackOrderId) { + public void preparePicking(String customerId, long positionId) { var sourceIds = new java.util.TreeSet(); attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); returnReceipts.available(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); - if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId); if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order"); for (Long orderId : sourceIds) { LastlegOrder order = orders.lockById(customerId, orderId); @@ -50,20 +80,8 @@ public class DispatchPickOrderAttributionService { } } - /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ - public List attribute(String customerId, DispatchPdaPick pick) { - return attribute(customerId, pick, null); - } - - /** - * Relabel inventory created before receipt-detail backfill may have no - * ll_inbound_stock_receipt row. Its dispatch item still carries the - * authoritative tail-leg order, so use that order only for relabel picks. - */ - public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) { - return attribute(customerId, pick, fallbackOrderId, null); - } - public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId, Long selectedReturnReceiptId) { + /** 普通实收与退回实收只按真实receipt归属;换标另走冻结贡献分支。 */ + public List attribute(String customerId, DispatchPdaPick pick, Long selectedReturnReceiptId) { long remainingPackages = value(pick.getPackages()); long totalPackages = remainingPackages; long totalQuantity = value(pick.getProductQuantity()); @@ -114,10 +132,6 @@ public class DispatchPickOrderAttributionService { pendingAttributions.add(row); pickedByOrder.merge(locked.getOrderId(), packages, Long::sum); remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; } - if (remainingPackages != 0 && selectedReturnReceiptId == null && fallbackOrderId != null && fallbackOrderId > 0) { - pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum); - remainingPackages = 0; - } if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); pendingAttributions.forEach(attributions::insert); for (Map.Entry entry : pickedByOrder.entrySet()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java new file mode 100644 index 000000000..aae359617 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 各端唯一端点常量;骨架本身不表示Controller已经上线。 */ +public final class DispatchApi { + public static final String PLATFORM = "/api/platform/v1/last-leg/dispatches"; + public static final String PDA = "/api/pda/v1/last-leg/dispatches"; + public static final String CANDIDATES = "/candidates"; + public static final String ITEMS = "/{dispatchNo}/items"; + public static final String REMOVE_ITEM = "/{dispatchNo}/items/{itemId}/remove"; + public static final String PICK_ITEM = "/{dispatchNo}/items/{itemId}/pick"; + public static final String CANCEL = "/{dispatchNo}/cancel"; + public static final String LOADING = "/{dispatchNo}/loading"; + public static final String DRAFTS = "/drafts"; + public static final String DRAFT_SNAPSHOT = "/drafts/snapshot"; + public static final String DRAFT_CONTAINERS = "/drafts/containers"; + public static final String DELETE_DRAFT_CONTAINER = "/drafts/{draftToken}/containers/{containerId}"; + public static final String DRAFT_LOCK = "/draft-locks/lock"; + public static final String DRAFT_UNLOCK = "/draft-locks/unlock"; + public static final String DRAFT_RELEASE = "/draft-locks/release"; + public static final String CONVERT_DRAFT = "/drafts/pre-arrange"; + public static final String COMMAND = "/{dispatchNo}/commands/{idempotencyKey}"; + public static final String SUBMIT_REVIEW = "/{dispatchNo}/submit-review"; + public static final String DISPATCH_REVIEW = "/{dispatchNo}/dispatch-review"; + public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review"; + public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review"; + public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete"; + public static final String DEPART = "/{dispatchNo}/depart"; + public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement"; + public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery"; + + /** 端点容器不允许实例化。 */ + private DispatchApi() { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java new file mode 100644 index 000000000..0bff02a42 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 既有预约改量专用内部端口,不注册HTTP,不恢复旧客户排车或手工Fleet绑定。 */ +public interface DispatchAppointmentAmendmentPort { + /** 必须由已锁定原运输scope读取;主流程仍逐项核对DB真实归属及完整实物量。 */ + record PhysicalItem(Long itemId,String customerId,Long planPalletId,String sourceStage,Integer packages) { } + + /** MANDATORY:一次锁全范围trip→全部车辆→司机/资源→全部dispatch,禁止逐车交错。 */ + void lockExistingAppointmentDispatches(Actor actor,List dispatchNos); + + /** + * MANDATORY:调用者先核对完整scope并锁齐原权威批次、来源及业务行。 + * identity.recordVersion是本车当前公共版本;idempotencyKey是原父预约命令键。 + * 实现按父键、可信操作者及dispatchNo派生稳定子键,共用原整车命令账本,重试恢复原快照。 + * expected/next必须来自原权威事实;removePhysicalItems只能由服务端完整实物守卫决定。 + * 只允许ACTIVE、BEFORE_LOADING、零真实扫码、无下游执行;任何失败跟随父事务整体回滚。 + */ + CommandResult applyExistingAppointmentChange(Actor actor,String dispatchNo,DispatchCommands.ReasonCommand identity, + List touchedItems,List expectedAllocations, + List nextAllocations,boolean removePhysicalItems); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java new file mode 100644 index 000000000..8b1ca3dee --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 公开整车写请求;所有已有单写入只使用recordVersion。 */ +public final class DispatchCommands { + /** 请求容器不允许实例化。 */ + private DispatchCommands() { } + + public record Vehicle(ResourceMode mode, Long vehicleId, String plate, String carrierName, + Capacity capacity, List evidenceFileIds) { } + + public record Driver(ResourceMode mode, Long driverId, String name, String phone, + DriverEvidence evidence) { } + + public record CargoSelection(SourceType sourceType, Long planPalletId, Long returnReceiptId, + String relabelNo, Long warehousePalletId, String sourceVersion) { } + + /** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */ + public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver, + Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, + List cargo, List batchAllocations) { } + + public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, + Instant plannedDepartureAt, Instant estimatedArrivalAt, + String remark) { + /** 缺版本与有效版本零必须区分。 */ + public Update { recordVersion = requireVersion(recordVersion); } + } + + public record AddCargo(Long recordVersion, String idempotencyKey, List cargo, + List batchAllocations) { + /** 缺版本不得退化成零版本。 */ + public AddCargo { recordVersion = requireVersion(recordVersion); } + } + + public record ReasonCommand(Long recordVersion, String idempotencyKey, String reason) { + /** 移货和取消同样必须提交当前版本。 */ + public ReasonCommand { recordVersion = requireVersion(recordVersion); } + } + + public record SubmitReview(Long recordVersion, String idempotencyKey, String remark) { + /** 审核提交不接受隐式零版本。 */ + public SubmitReview { recordVersion = requireVersion(recordVersion); } + } + + public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision, + String reason) { + /** 两级审核都执行相同的必填版本约束。 */ + public ReviewCommand { recordVersion = requireVersion(recordVersion); } + } + + /** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */ + public record ReturnToReview(Long recordVersion, String idempotencyKey, String reason) { + /** 零扫码退回也不能覆盖并发新版本。 */ + public ReturnToReview { recordVersion = requireVersion(recordVersion); } + } + + public record Pick(Long recordVersion, String idempotencyKey, Long positionId, long packages, + long productQuantity, String cartonMark, String remark) { + /** 扫码缺版本必须失败,避免将旧请求解释为首版。 */ + public Pick { recordVersion = requireVersion(recordVersion); } + } + + /** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */ + public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash, + List measurements, String remark) { + /** 后台与PDA共用必填版本约束。 */ + public LoadingComplete { recordVersion = requireVersion(recordVersion); } + } + + public record Depart(Long recordVersion, String idempotencyKey, String remark) { + /** 实际发车不得接受缺省版本。 */ + public Depart { recordVersion = requireVersion(recordVersion); } + } + + /** 装箱类型保留JSON缺字段事实,同时允许显式提交零。 */ + static Long requireVersion(Long recordVersion) { + if (recordVersion == null || recordVersion < 0) throw new IllegalArgumentException("recordVersion必填且不得小于零"); + return recordVersion; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java new file mode 100644 index 000000000..eba03e4bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dispatch.port; + +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 主流程独占实现;阶段CAS与本信号确认在同一新事务提交。 */ +public interface DispatchCompletionPort { + /** 在签收提交后取得trip/dispatch锁,真实全签才推进,失败不确认信号。 */ + CompletionResult reconcile(Signal signal); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java new file mode 100644 index 000000000..b3940213d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 主流程与派送域之间的事实快照;不允许传递持久化实体。 */ +public final class DispatchContracts { + /** 契约容器不允许实例化。 */ + private DispatchContracts() { } + + /** 由可信认证上下文构造,不允许从请求体绑定操作身份或PDA仓库。 */ + public record Actor(String operatorId, ActorMode mode, String boundWarehouseId) { } + + /** 未允许时必须携带稳定错误码和可展示原因。 */ + public record Gate(boolean allowed, String blockCode, String blockReason) { } + + public record Counts(long itemCount, long palletCount, long packageCount, + long pickedPackageCount, long shippedPackageCount) { } + + public record Capacity(Integer palletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } + + /** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */ + public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, + List fileIds, Instant expectedTaskEndAt) { } + + public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate, + String carrierName, String vehicleStatus, Capacity capacity, + List evidenceFileIds) { } + + public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone, + DriverEvidence evidence) { } + + public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver) { } + + public record Destination(String key, String type, String warehouseId, String warehouseCode, + String warehouseName, String address, String postalCode, + String contactName, String contactPhone) { } + + public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt, + long reviewRound, String basisHash, String invalidatedReason) { } + + public record Reviews(Review dispatchReview, Review loadingReview) { } + + public record BatchAllocation(Long planPalletId, String batchKey, String customerId, + String sourceKey, String destinationKey, int packageCount, Long recordVersion) { + /** 本字段属于batchKey的权威记录,不是整车版本;必须显式提交,零合法。 */ + public BatchAllocation { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } + + public record BatchSource(String sourceKey, String destinationKey, int packageCount) { } + + public record BatchReference(String batchKey, String batchNo, String customerId, + List sources, Long recordVersion) { + /** FBA使用原预约版本,自动DISPATCH引用使用该真实主单版本。 */ + public BatchReference { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } + + /** 普通换标本次归属的原货源快照,不以换标履约单或整托货量冒充。 */ + public record SourceOrigin(Long dispatchItemSourceId, Long relabelApplicationId, Long relabelItemId, + Long sourceInventoryBalanceId, Long sourceInboundOrderId, + String sourceInboundOrderNo, String sourceInboundCode, Long productQuantity) { } + + /** 一行表示本次不可变真实拣货来源,展示入仓码不能替代此归属。 */ + public record CargoLine(Long dispatchItemId, String customerId, String customerName, + Long orderId, String orderNo, String sourceKey, String sourceStage, + String sourceRegion, String sourceInboundId, String inboundCode, + Long planPalletId, Long warehousePalletId, String palletNo, + Long pickId, Long attributionId, Long inventoryPositionId, + Long receiptId, Long returnReceiptId, Long returnCartonId, + String cartonMark, String relabelNo, Destination destination, + long packages, long productQuantity, BigDecimal grossWeightKg, + BigDecimal volumeCbm, List batchAllocations, + SourceOrigin sourceOrigin) { } + + /** 仅用于服务内协作;锁定上下文并不等于通过当前版本或阶段资格。 */ + public record Context(Long dispatchId, String dispatchNo, Long tripId, String tripNo, + String warehouseId, String warehouseCode, String warehouseName, + Status status, Stage stage, long recordVersion, long cargoRevision, + String cargoHash, String loadingCompletionHash, Instant loadingCompletedAt, + Instant departedAt, Resources resources, Counts counts, Reviews reviews, + boolean pickingReconciled, List cargo) { } + + /** 内部命令身份由服务端摘要器产生,公开请求不接受commandHash。 */ + public record CommandIdentity(Action action, String idempotencyKey, long recordVersion, + String commandHash) { } + + public record CommandResult(String dispatchNo, Action action, String idempotencyKey, + long recordVersion, Status status, Stage stage, Instant occurredAt, + Effect effect, Counts counts, DispatchResults.Payload result, + boolean replayed, boolean alreadyArranged) { } + + public record LoadingMeasurement(Long warehousePalletId, int palletSpaces, BigDecimal weightKg, + BigDecimal volumeCbm, int deliveryIndex) { } + + public record ConfirmedLoad(String manifestHash, List pallets, + String operatorId, Instant confirmedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java new file mode 100644 index 000000000..e5632484b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 派送安排、费用证据与正常签收的共享边界,均保留真实客户。 */ +public final class DispatchDeliveryModels { + /** 派送契约容器不允许实例化。 */ + private DispatchDeliveryModels() { } + + public record CoverageSelection(Long financeFeeId, int sourceVersion, String sourceHash, + List services) { } + + public record FeeItem(String feeType, String feeName, String direction, String currency, + BigDecimal amount, Long supplierId, String supplierName, String supplierCategory, + String settlementMode, Instant paidAt, String paymentMethod, String paymentReferenceNo, + List paymentVoucherFileIds, BigDecimal chargeableQuantity, String quantityUnit, + BigDecimal unitPrice, LocalDate periodStart, LocalDate periodEnd, String remark) { } + + public record ArrangeGroup(String groupKey, String groupCargoHash, Instant plannedDeliveryAt, + String deliveryMethod, String waybillNo, String carrierName, String pickupMethod, + String loadingType, String vehicleType, String vehicleSize, String appointmentAccount, + String sellerName, String remark, String externalFreightHash, String feeCoverageCargoHash, + List feeCoverage, List fees) { } + + public record ArrangeDelivery(Long recordVersion, String idempotencyKey, String cargoHash, + String loadingCompletionHash, List groups) { + /** 安排派送缺版本不得被反序列化为有效零。 */ + public ArrangeDelivery { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record OutboundReference(String customerId, String customerName, String outboundNo, + String status, long packageCount, long palletCount) { } + + public record DeliveryReference(String groupKey, String customerId, String customerName, + String outboundNo, String deliveryNo, Destination destination, + String status, long packageCount, long palletCount) { } + + public record ArrangementResult(String dispatchNo, long recordVersion, String arrangementKey, + String cargoHash, Instant arrangedAt, List outbounds, + List deliveries) implements DispatchResults.Payload { } + + public record ExternalQuote(String customerId, String groupKey, String bookingNo, String inboundCode, + String currency, BigDecimal amount, String sourceHash) { } + + public record FeeCandidate(Long financeFeeId, String customerId, String currency, BigDecimal amount, + int sourceVersion, String sourceHash, String status, List services, + List sourceKeys) { } + + public record PreviewGroup(String groupKey, String customerId, String customerName, Destination destination, + String groupCargoHash, List cargo, String externalFreightHash, + List externalQuotes, String feeCoverageCargoHash, + List feeSources, List requiredServices, + List blockReasons) { } + + public record ArrangementPreview(String dispatchNo, long recordVersion, Status status, Stage stage, + String cargoHash, String loadingCompletionHash, Gate arrange, + List groups, ArrangementResult arrangement) { } + + /** 只认本车全部客户单据完整覆盖,lock模式由调用方父事务提供。 */ + public record DepartureFacts(String dispatchNo, String arrangementKey, String cargoHash, + String loadingCompletionHash, String arrangementBusinessHash, + List outbounds, List deliveries, + List cargo, Gate depart) { } + + public record DepartureResult(List outbounds, List deliveries, + long shippedPackages) { } + + public record NormalDeliveryFact(String customerId, String groupKey, String outboundNo, + String deliveryNo, long deliveryVersion, long originalPackages, + long signedPackages, long returnedPackages, long activeReturnCount, + String status, boolean outboundCompleted, Instant signedAt) { } + + public record NormalDeliveryFacts(String dispatchNo, String factsHash, boolean allNormallySigned, + Instant completedAt, List expectedDeliveryNos, + List deliveries, List blockReasons) { } + + public record ArrangementFact(String arrangementKey, String arrangementBusinessHash, + String cargoHash, String loadingCompletionHash, ArrangementResult result) { } + + public record Signal(Long signalId, String dispatchNo, String deliveryNo, long deliveryVersion, + int attempts, Instant nextRetryAt) { } + + public record CompletionResult(String dispatchNo, Stage stage, long recordVersion, + boolean changed, boolean allNormallySigned, Instant completedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java new file mode 100644 index 000000000..11fea00de --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */ +public interface DispatchDeliveryPort { + /** 一次预览返回同快照下全部客户目的地及费用证据。 */ + ArrangementPreview preview(Context context); + + /** 平台整车安排;使用主流程端口获得父锁和共同命令结果。 */ + CommandResult arrange(Actor actor, String dispatchNo, ArrangeDelivery request); + + /** lock为真时先锁齐本车全部outbound/delivery,再允许来源/费用锁。 */ + DepartureFacts departureFacts(Context context, boolean lock); + + /** 加入发车父事务,仅更新真实客户单据/计数,不执行库存SHIP。 */ + DepartureResult markDeparted(Context context, Actor actor, Instant departedAt, String commandKey); + + /** 普通读取全部本趟签收事实,不锁历史来源,不调用跨趟来源证明。 */ + NormalDeliveryFacts normalDeliveryFacts(Context context); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java new file mode 100644 index 000000000..a1ed11086 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.Optional; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 主流程实现的整车权威端口;写方法必须加入调用者已有事务。 */ +public interface DispatchExecutionPort { + /** 只读返回当前真实货物,不授予写资格或取得子单锁。 */ + Context preview(Actor actor, String dispatchNo); + + /** 双端按当前权限/绑定仓读取真实实载及缺项,不补造PDA数量或预约。 */ + DispatchPlanningModels.LoadingPreview loading(Actor actor, String dispatchNo); + + /** 取得trip→必要资源→dispatch;原命令恢复优先,不提前拒绝旧版本。 */ + Context lockForArrangement(Actor actor, String dispatchNo); + + /** 当前权限/仓核验后查全局请求键;异主体或异摘要必须拒绝。 */ + Optional replay(Context context, Actor actor, CommandIdentity identity); + + /** 新命令复验当前版本、有效状态、装车后和完整真实拣货。 */ + void requireFreshArrangement(Context context, long recordVersion); + + /** 与客户单据/费用同事务记录安排引用及公共命令;既有安排不得再执行。 */ + CommandResult recordArrangement(Context context, Actor actor, CommandIdentity identity, + ArrangementFact fact, boolean alreadyArranged); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java new file mode 100644 index 000000000..0ee629c41 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 候选、草稿和实载读取种子,不生成日历预约或虚构实测。 */ +public final class DispatchPlanningModels { + /** 规划契约容器不允许实例化。 */ + private DispatchPlanningModels() { } + + public record CandidateQuery(String warehouseId, List planIds, String orderNo, + String destinationWarehouseCode, String sourceStage, String draftToken) { } + + /** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */ + public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion, + String customerId, String customerName, String warehouseId, String warehouseCode, + String warehouseName, String orderNo, String inboundCode, String planNo, + String palletNo, String containerNo, Destination destination, long availablePackages, + BigDecimal suggestedWeightKg, BigDecimal suggestedVolumeCbm, String measurementSource, + List appointmentBatches, List blockReasons, + SelectionGroup selectionGroup) { } + + /** 服务端核齐实托与整申请闭包;缺组、缺版本或complete=false均不能作为完整选货证明。 */ + public record SelectionGroup(String groupKey, String groupVersion, List sources, + List pallets, boolean complete, List blockReasons) { } + + /** 每个逻辑来源保留真实客户及业务号,selection携带该来源的权威版本。 */ + public record SelectionGroupSource(DispatchCommands.CargoSelection selection, String sourceKey, + String customerId, String customerName, String sourceBusinessType, + String sourceBusinessNo, long packages) { } + + /** 实托只列一次;sourceKeys完整列出其逻辑贡献,packageCount为这些贡献的真实合计。 */ + public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List sourceKeys, + long packageCount) { } + + public record CandidateList(List candidates, Instant snapshotAt, boolean complete) { } + + public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity, + List sourceKeys) { } + + public record Draft(String draftToken, long recordVersion, String status, String warehouseId, + List containers, List sources, Instant expiresAt) { } + + public record CreateDraft(String idempotencyKey, String warehouseId) { } + + public record CreateContainer(Long recordVersion, String idempotencyKey, String draftToken, int capacity) { + /** 已建草稿也必须区分缺版本和首版零。 */ + public CreateContainer { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftSelection(Long recordVersion, String idempotencyKey, String draftToken, + Long draftContainerId, DispatchCommands.CargoSelection source) { + /** 锁定或移除来源时不能覆盖并发草稿修订。 */ + public DraftSelection { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftSnapshot(Long recordVersion, String idempotencyKey, String draftToken, + List planIds, List containers) { + /** 草稿布局同样要求显式版本。 */ + public DraftSnapshot { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftRelease(Long recordVersion, String idempotencyKey, String draftToken) { + /** 释放仅作用于原版本/原拥有者,不能覆盖新选择。 */ + public DraftRelease { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + /** 一个服务器草稿容器原子生成一张整车,不按客户再拆子排车。 */ + public record DraftVehicle(Long draftContainerId, DispatchCommands.Create dispatch) { } + + public record ConvertDraft(Long recordVersion, String idempotencyKey, String draftToken, + List groups) { + /** 正式转换按已核对草稿版本执行,不重复生成车单。 */ + public ConvertDraft { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record ConvertedVehicle(Long draftContainerId, CommandResult dispatch) { } + + public record ConvertedDraft(String draftToken, List vehicles, boolean replayed) { } + + /** 非实测来源只能作为建议展示,不能填入实际测量值。 */ + public record MeasurementEvidence(String field, String sourceType, String sourceBusinessNo, + Instant observedAt, BigDecimal value, boolean actual) { } + + public record LoadingPallet(Long warehousePalletId, String palletNo, long scheduledPackages, + long pickedPackages, Integer palletSpaces, BigDecimal weightKg, + BigDecimal volumeCbm, Integer deliveryIndex, List missingFields, + List evidence) { } + + public record LoadingPreview(String dispatchNo, long recordVersion, DispatchTypes.Status status, + DispatchTypes.Stage stage, String manifestHash, boolean pickingReconciled, + Resources resources, List pallets, ConfirmedLoad confirmedLoad, + Gate loadingComplete, List missingFields) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java new file mode 100644 index 000000000..382abe968 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; + +/** 主流程独占实现候选与草稿,不按客户拆成多个排车头。 */ +public interface DispatchPlanningPort { + /** 有界批量解析真实来源、客户和事实摘要,不能跨仓或静默截断。 */ + CandidateList candidates(Actor actor, CandidateQuery query); + + /** 只查询当前操作者拥有的未失效草稿。 */ + List drafts(Actor actor); + + /** 持久幂等创建草稿,返回服务端token及合法零版本。 */ + Draft createDraft(Actor actor, CreateDraft request); + + /** 在草稿版本和拥有者锁内新增装车容器。 */ + Draft createContainer(Actor actor, CreateContainer request); + + /** 保存布局并按服务端来源占用重核,不信任布局JSON赋予货物资格。 */ + Draft saveSnapshot(Actor actor, DraftSnapshot request); + + /** 比对sourceVersion后取得真实来源/实托排他占用。 */ + Draft select(Actor actor, DraftSelection request); + + /** 仅解除本草稿本容器的原来源,不释放他人新占用。 */ + Draft unselect(Actor actor, DraftSelection request); + + /** 以当前草稿版本移除容器并归还其未执行来源。 */ + Draft removeContainer(Actor actor, String draftToken, Long containerId, DispatchCommands.ReasonCommand request); + + /** 幂等释放当前操作者的原草稿。 */ + Draft release(Actor actor, DraftRelease request); + + /** 一个事务转换全部组,一组一整车,任意客户失败全批回滚。 */ + ConvertedDraft convert(Actor actor, ConvertDraft request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java new file mode 100644 index 000000000..b78185820 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +/** 不可变命令结果的数据类型,恢复不得改成当前详情。 */ +public final class DispatchResults { + /** 结果容器不允许实例化。 */ + private DispatchResults() { } + + public sealed interface Payload permits Saved, Picked, Loaded, Departed, + DispatchDeliveryModels.ArrangementResult { } + + public record Saved(String dispatchNo, List changedItemIds) implements Payload { } + + public record Picked(Long dispatchItemId, Long positionId, Long returnCartonId, long packages, + long pickedPackages, boolean pickingReconciled) implements Payload { } + + public record Loaded(String loadingCompletionHash, Instant loadingCompletedAt, + DispatchContracts.ConfirmedLoad confirmedLoad) implements Payload { } + + public record Departed(Instant departedAt, long shippedPackages, + List outbounds, + List deliveries) implements Payload { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java new file mode 100644 index 000000000..3b335d48d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 派送域实现持久提醒数据访问;调度、退避和重扫由主流程负责。 */ +public interface DispatchSignalPort { + /** 有界普通读到期信号,不持信号或派送锁进入父事务。 */ + List due(Instant now, int limit); + + /** MANDATORY:与主阶段CAS同一汇总事务确认,失败全部回滚。 */ + void acknowledge(Signal signal, Instant acknowledgedAt); + + /** REQUIRES_NEW:仅在父汇总回滚后独立更新失败次数及退避时间。 */ + void failed(Signal signal, Instant nextRetryAt, String errorCode); + + /** 在原签收事务中持久去重写入,不回调父流程或自动推进阶段。 */ + void enqueue(String dispatchNo, String deliveryNo, long deliveryVersion, Instant occurredAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java new file mode 100644 index 000000000..69a90951b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 整车作业各端共用的唯一枚举,不复用客户子单或车次状态。 */ +public final class DispatchTypes { + /** 枚举容器不允许实例化。 */ + private DispatchTypes() { } + + public enum Status { ACTIVE, CANCELLED } + public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED } + public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED } + public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED } + public enum Decision { APPROVED, REJECTED } + public enum ResourceMode { MASTER, EXTERNAL } + public enum ActorMode { PLATFORM, PDA } + public enum SourceType { INBOUND_PLAN, RETURN_RECEIPT, RELABEL } + public enum BeforeLoadingSection { VEHICLE_INFO, DISPATCH_REVIEW, LOADING_REVIEW } + public enum StatusFilter { ACTIVE, CANCELLED, ALL } + public enum Effect { APPLIED, EXISTING } + public enum Action { + CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW, + LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java new file mode 100644 index 000000000..75f767fdd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -0,0 +1,79 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 列表及四页签展示对象,不将当前页统计冒充全量统计。 */ +public final class DispatchViews { + /** 展示容器不允许实例化。 */ + private DispatchViews() { } + + public record Query(long page, long size, String dispatchNo, String vehiclePlate, + String warehouseId, String customerId, String fbaAppointmentNo, + StatusFilter status, Stage stage, BeforeLoadingSection beforeLoadingSection, + ReviewStatus reviewStatus) { } + + public record ReviewCounts(long notSubmitted, long pending, long approved, long rejected) { } + + public record BeforeLoadingCounts(long vehicleInfoTotal, ReviewCounts dispatchReview, + ReviewCounts loadingReview) { } + + public record Statistics(long matchedTotal, long activeTotal, long cancelledTotal, + Map stageCounts, BeforeLoadingCounts beforeLoadingReviewCounts) { } + + public record Page(List records, long total, long page, long size, long pages, + Statistics statistics, Instant snapshotAt) { } + + public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion, + ReviewState reviewState, Reviews reviews, Resources resources, + String warehouseId, String warehouseCode, String warehouseName, + Instant plannedDepartureAt, Instant departedAt, Counts counts, + boolean pickingReconciled, boolean arranged, + Map actions) { } + + public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver) { } + + public record FbaAppointment(String batchKey, String batchNo, String appointmentNo, + String destinationKey, String destinationWarehouseCode, + String status, Instant confirmedAt, String blockReason) { } + + public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt, + Instant estimatedArrivalAt, List fbaAppointments, + List appointmentBatches) { } + + public record Documents(String dispatchNo, List outbounds, + List deliveries, + Instant createdAt, Instant updatedAt, String remark) { } + + public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode, + BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse, + BigDecimal volumeCbm, String containerNo, String sourceKey, + String sourceBusinessType, String sourceBusinessNo) { } + + /** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */ + public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion, + ReviewState reviewState, Reviews reviews, Counts counts, + boolean pickingReconciled, Map actions, + VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents, + List orderDetails) { } + + public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, + Stage stage, long recordVersion, Reviews reviews, Counts counts, + boolean pickingReconciled, List items, Map actions) { } + + public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo, + Destination destination, long scheduledPackages, long pickedPackages, + List positions, List returnCartons, + String sourceKey, String sourceBusinessType, String sourceBusinessNo) { } + + public record Position(Long positionId, Long balanceId, long availablePackages, + long allocatedPackages, long pickedPackages) { } + + public record ReturnCarton(Long returnCartonId, String cartonMark, String status, + long productQuantity) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java new file mode 100644 index 000000000..d2f1b708b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 四详情对象之外的作业弹框读取,不以展示字段代替真实明细或卸货顺序。 */ +public final class DispatchWorkModels { + /** DTO容器不实例化。 */ + private DispatchWorkModels(){ } + public record CargoItem(Long dispatchItemId,String customerId,String customerName,String sourceKey,String sourceVersion, + SourceType sourceType,Long planPalletId,Long returnReceiptId,String relabelNo,Long warehousePalletId,String palletNo, + String orderNo,String inboundCode,Destination destination,long scheduledPackages,long pickedPackages,Gate remove) { } + public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List items) { } + public record Stop(int index,String name,String address,String destinationKey) { } + public record Route(String dispatchNo,long recordVersion,List stops,Gate edit) { } + /** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */ + public record RouteCommand(Long recordVersion,String idempotencyKey,List destinationKeys,String reason) { + /** 显式零版本合法,缺版本不能覆盖并发变更。 */ + public RouteCommand { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java new file mode 100644 index 000000000..222f415ee --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 整车读写持久化入口;业务流转与事务由服务负责。 */ +@Repository +public class DispatchAggregateRepository { + private final DispatchAggregateMapper mapper; + private final DispatchItemMapper items; + private final DispatchReadMapper reads; + + /** 只注入本域Mapper,服务不直接执行SQL。 */ + public DispatchAggregateRepository(DispatchAggregateMapper mapper, DispatchItemMapper items, DispatchReadMapper reads) { + this.mapper = mapper; this.items = items; this.reads=reads; + } + /** 新建真整车。 */ + public int insert(DispatchAggregate row) { return mapper.insert(row); } + /** 状态、审核、资源和实际执行事实以同一公共版本CAS保存。 */ + public int saveState(DispatchAggregate row,long originalVersion) { return mapper.saveState(row,originalVersion); } + /** 真实扫码流水优先于主单冗余计数。 */ + public long realPicked(long id) { return reads.realPicked(id); } + /** 读取业务号对应整车。 */ + public DispatchAggregate find(String number) { return mapper.find(number); } + /** 同一快照批量读整车,空集合不生成无效IN SQL。 */ + public List findMany(List numbers) { return numbers.isEmpty()?List.of():mapper.findMany(numbers); } + /** 在调用方已取得资源父锁后锁整车。 */ + public DispatchAggregate lock(String number) { return mapper.lock(number); } + /** 整车分页。 */ + public List page(DispatchAggregateMapper.Search query) { return mapper.page(query); } + /** 整车分页总数。 */ + public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); } + /** 全搜索基集的阶段和审核分布。 */ + public List summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); } + /** 按整车读取全部真实客户明细。 */ + public List cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); } + /** 保存已核对真实客户与仓库的来源快照。 */ + public int insertItem(DispatchItem item) { return items.insert(item); } + /** 未执行明细才可移除,资格和根版本由应用层保证。 */ + public int removeItem(long itemId) {return items.deleteById(itemId);} + /** 原子更新货量并作废旧审核依据。 */ + public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages, + String route, String pending, LocalDateTime at) { + return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at); + } + /** 追加不可变业务快照。 */ + public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) { + return mapper.history(id,row.getId(),row.getRecordVersion(),action,operator,reason,snapshot,at); + } + /** 同车全部客户出库摘要。 */ + public List outbounds(long id) { return reads.outbounds(id); } + /** 同车全部客户目的地派送摘要。 */ + public List deliveries(long id) { return reads.deliveries(id); } + /** 已有关联预约批次只读事实。 */ + public List batches(long id) { return reads.batches(id); } + /** 是否必须走受保护提供者核验批次分配。 */ + public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java new file mode 100644 index 000000000..dfab76006 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import com.baiyue.lastleg.dispatch.mapper.DispatchCommandMapper; +import org.springframework.stereotype.Repository; + +/** 命令账本的数据入口,不承担权限、状态或幂等业务决定。 */ +@Repository +public class DispatchCommandRepository { + private final DispatchCommandMapper mapper; + + /** 注入本服务显式注册的命令Mapper。 */ + public DispatchCommandRepository(DispatchCommandMapper mapper) { this.mapper = mapper; } + + /** 按全局请求键查询原不可变结果。 */ + public DispatchCommandRecord find(String key) { return mapper.find(key); } + + /** 原子新增成功命令,不能更新覆盖既有行。 */ + public int insert(DispatchCommandRecord row) { return mapper.insert(row); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java new file mode 100644 index 000000000..a0783c6d1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.mapper.*; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 草稿命令、根版本、布局和自身占用的数据访问;事务及资格由专用服务编排。 */ +@Repository +public class DispatchDraftRepository { + private final DispatchDraftMapper drafts; + private final DispatchDraftContainerMapper containers; + private final DispatchDraftCommandMapper commands; + private final DispatchDraftClaimMapper claims; + + /** 仅装配本有界包Mapper,来源资格仍取既有提供者。 */ + public DispatchDraftRepository(DispatchDraftMapper drafts,DispatchDraftContainerMapper containers, + DispatchDraftCommandMapper commands,DispatchDraftClaimMapper claims) { + this.drafts=drafts;this.containers=containers;this.commands=commands;this.claims=claims; + } + /** 读取当前拥有者的未失效草稿。 */ + public List active(String owner){return drafts.findActiveByOperator(owner);} + /** 在唯一命令之后取得拥有者根锁。 */ + public DispatchDraft lock(String token,String owner){return drafts.lockOwned(token,owner);} + /** 新建服务器生成身份的草稿。 */ + public void insert(DispatchDraft row){if(drafts.insert(row)!=1)throw new IllegalStateException("草稿未保存");} + /** 公共版本CAS失败必须回滚本次全部写入。 */ + public void save(DispatchDraft row,long version){if(drafts.save(row,version)!=1)throw new IllegalStateException("草稿版本竞争失败");} + /** 布局普通读始终在草稿根事务中使用。 */ + public List containers(String token){return containers.findByToken(token);} + /** 保存真实新容器。 */ + public void insert(DispatchDraftContainer row){if(containers.insert(row)!=1)throw new IllegalStateException("草稿容器未保存");} + /** 布局交换先腾出本草稿原序号,异常由外层事务整体回滚。 */ + public void stageSequences(String token){containers.stageSequences(token);} + /** 写回服务端校验后的完整布局。 */ + public void layout(DispatchDraftContainer row){if(containers.layout(row.getId(),row.getDraftToken(),row.getSequenceNo(),row.getCapacity(),row.getSourceKeysJson(),row.getUpdatedAt())!=1)throw new IllegalStateException("草稿容器归属已变化");} + /** 只删除已核对为空的本容器。 */ + public void remove(String token,long id){if(containers.remove(id,token)!=1)throw new IllegalStateException("草稿容器不存在");} + /** 全局草稿命令键竞争采用数据库唯一约束。 */ + public DispatchDraftCommand reserve(DispatchDraftCommand row){commands.reserve(row);return commands.lock(row.getIdempotencyKey());} + /** 原始结果随业务事务共同完成。 */ + public void complete(long id,String json){if(commands.complete(id,json)!=1)throw new IllegalStateException("草稿命令结果未保存");} + /** 来源提供者已取根锁后,锁本草稿活动占用。 */ + public List claims(DispatchDraft row,LocalDateTime now){return claims.owned(row.getDraftToken(),row.getOperatorId(),now);} + /** 逻辑来源引用物理提供者已取得的共享占用,保留本客户独立来源身份。 */ + public void claim(long id,DispatchDraft row,long container,DispatchServiceSource source,LocalDateTime now){ + if(claims.insert(id,source.customer(),source.key(),source.plan(),source.pallet(),source.receipt(),container,row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt(),source.physicalLockId())!=1)throw new IllegalStateException("来源占用未保存"); + } + /** 数据访问输入只包含提供者已经解析的真实标识。 */ + public record DispatchServiceSource(String customer,String key,Long plan,Long pallet,Long receipt,Long physicalLockId) { } + /** 移动只针对同草稿原容器记录,零行不能被当作成功。 */ + public void move(DispatchDraft row,DispatchDraftClaimMapper.Claim claim,long next,LocalDateTime now){if(claims.move(claim.id(),row.getDraftToken(),row.getOperatorId(),claim.containerId(),next,now)!=1)throw new IllegalStateException("原草稿占用已变化");} + /** 释放限定原token、owner及尚未转换的草稿记录。 */ + public int release(DispatchDraft row,String key,Long container,LocalDateTime now){return claims.release(row.getDraftToken(),row.getOperatorId(),key,container,now);} + /** 活动草稿修改时同步续期原占用。 */ + public int renew(DispatchDraft row,LocalDateTime now){return claims.renew(row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java new file mode 100644 index 000000000..461a34c01 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 真实执行读取只经本域Mapper,流程判断留给应用服务。 */ +@Repository +public class DispatchExecutionRepository { + private final DispatchExecutionReadMapper reads; + /** 装配批量查询。 */ + public DispatchExecutionRepository(DispatchExecutionReadMapper reads){this.reads=reads;} + /** 全车所有客户的真实扫码来源。 */ + public List sources(long id){return reads.sources(id);} + /** 每项实际已拣数量。 */ + public List itemPicked(long id){return reads.itemPicked(id);} + /** 不完整归属不能核平。 */ + public long inconsistentPicks(long id){return reads.inconsistentPicks(id);} + /** 可自动带入的实测证据。 */ + public List actualMeasurements(long id){return reads.actualMeasurements(id);} + /** 没有持久信号的已签收版本仍可通过真实单据重建通知。 */ + public List rescan(int limit){return reads.rescan(limit);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java new file mode 100644 index 000000000..34792a750 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchItemSource; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 换标来源冻结快照的持久化边界,不执行物理库存或履约状态命令。 */ +@Repository +public class DispatchItemSourceRepository { + private final DispatchItemSourceMapper mapper; + /** 只装配本域真实来源快照Mapper。 */ + public DispatchItemSourceRepository(DispatchItemSourceMapper mapper){this.mapper=mapper;} + /** 唯一贡献键阻止同一item/位置被重复冻结。 */ + public int insert(DispatchItemSource source){return mapper.insert(source);} + /** 批量读取整车真实贡献。 */ + public List all(long dispatch){return mapper.all(dispatch);} + /** 本批主单的真实贡献一次读取。 */ + public List batchAll(List ids){return ids.isEmpty()?List.of():mapper.batchAll(ids);} + /** 本批全部贡献的实际已消费额度一次读取。 */ + public List pickedAll(List ids){return ids.isEmpty()?List.of():mapper.pickedAll(ids);} + /** 新归属分支必须关联真实冻结贡献。 */ + public DispatchItemSource find(long id){return mapper.selectById(id);} + /** 所选位置仍受本客户及本逻辑来源约束。 */ + public List position(long dispatch,long item,String customer,long position){return mapper.position(dispatch,item,customer,position);} + /** 已消费额度只取本来源不可变归属。 */ + public long picked(String customer,long source){return mapper.picked(customer,source);} + /** 仅删除原主单原明细且尚无扫码的贡献。 */ + public int removeUnpicked(long dispatch,long item){return mapper.removeUnpicked(dispatch,item);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java index b52211565..5347b9a33 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java @@ -1,60 +1,16 @@ package com.baiyue.lastleg.dispatch.repository; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baiyue.lastleg.dispatch.entity.*; -import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.delivery.entity.OutboundOrder; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import java.util.List; -/** 排车变更的数据操作,事务及状态规则由排车服务维护。 */ +/** 整车来源规则对原换标申请的事务内数据操作。 */ public class DispatchMutationRepository { - private final DispatchOrderMapper orders; - private final DispatchItemMapper items; - private final DispatchPalletLockMapper locks; - private final OutboundOrderMapper outbounds; private final DispatchCandidateMapper candidates; private final RelabelApplicationMapper relabels; - private final DispatchPdaTaskMapper tasks; - - /** 复用现有 Mapper 装配,供原排车服务分步收口变更路径。 */ - public DispatchMutationRepository(DispatchOrderMapper orders, DispatchItemMapper items, DispatchPalletLockMapper locks, - OutboundOrderMapper outbounds, DispatchCandidateMapper candidates, RelabelApplicationMapper relabels, DispatchPdaTaskMapper tasks) { - this.orders=orders; this.items=items; this.locks=locks; this.outbounds=outbounds; - this.candidates=candidates; this.relabels=relabels; this.tasks=tasks; - } - /** 按业务号锁定唯一排车。 */ - public DispatchOrder lockOrder(String dispatchNo) { return orders.lockGlobalByDispatchNo(dispatchNo); } - /** 保存排车的业务变更。 */ - public void saveOrder(DispatchOrder order) { orders.updateById(order); } - /** 创建已校验客户和完整货量的新排车。 */ - public void createOrder(DispatchOrder order) { orders.insert(order); } - /** 读取当前客户排车的原始明细。 */ - public List items(DispatchOrder order) { return items.findByDispatchOrder(order.getCustomerId(), order.getId()); } - /** 锁定本排车的指定托盘明细。 */ - public DispatchItem lockItem(DispatchOrder order, Long itemId) { return items.lockById(order.getCustomerId(), order.getId(), itemId); } - /** 保存新换标排车明细。 */ - public void createItem(DispatchItem item) { items.insert(item); } - /** 保存退货换标来源快照。 */ - public void saveItem(DispatchItem item) { items.updateById(item); } - /** 删除尚未执行的排车托盘,历史单据由取消命令保留。 */ - public void removeItem(Long itemId) { items.deleteById(itemId); } - /** 读取该排车自己的活动占用,不触碰后续新排车。 */ - public List activeLocks(DispatchOrder order) { - return locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, order.getCustomerId()).eq(DispatchPalletLock::getDispatchOrderId, order.getId()) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); - } - /** 保存单条原排车占用的释放事实。 */ - public void saveLock(DispatchPalletLock lock) { locks.updateById(lock); } - /** 读取或锁定历史关联出库;业务层区分活动与取消。 */ - public OutboundOrder outbound(DispatchOrder order, boolean lock) { - return lock ? outbounds.lockByDispatch(order.getCustomerId(), order.getId()) : outbounds.findByDispatch(order.getCustomerId(), order.getId()); + public DispatchMutationRepository(DispatchCandidateMapper candidates,RelabelApplicationMapper relabels) { + this.candidates=candidates; this.relabels=relabels; } /** 锁定换标申请,串行完整候选检查与整单状态推进。 */ public RelabelApplication lockRelabel(String customer, String number) { return relabels.lockNo(customer, number); } @@ -64,6 +20,4 @@ public class DispatchMutationRepository { public int markRelabel(String customer, String number) { return candidates.markRelabelPreDispatched(customer, number); } /** 取消未执行排车后恢复原申请候选。 */ public void restoreRelabel(String customer, String number) { candidates.restoreRelabelPreDispatch(customer, number); } - /** 创建与新排车同事务的 PDA 任务。 */ - public void createTask(DispatchPdaTask task) { tasks.insert(task); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java new file mode 100644 index 000000000..a3e526e65 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 整车PDA批量读投影,空集合由应用层短路,避免无范围查询。 */ +@Repository +public class DispatchPdaReadRepository { + private final DispatchWholePdaReadMapper mapper; + /** 装配本域读Mapper。 */ + public DispatchPdaReadRepository(DispatchWholePdaReadMapper mapper){this.mapper=mapper;} + /** 绑定仓真实主单。 */ + public List headers(String warehouse,String number,String stage){return mapper.headers(warehouse,number,stage);} + /** 一次取齐当前根集合的所有客户货物。 */ + public List items(List ids){return mapper.items(ids);} + /** 实际扫码计数。 */ + public List picked(List ids){return mapper.picked(ids);} + /** 归属不完整不能核平。 */ + public List invalid(List ids){return mapper.invalid(ids);} + /** 本批真实客户库存仓位。 */ + public List positions(List ids,String warehouse){return mapper.positions(ids,warehouse);} + /** 本批原生退货箱。 */ + public List cartons(List ids,String warehouse){return mapper.cartons(ids,warehouse);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java new file mode 100644 index 000000000..1305a7241 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchPhysicalClaimMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 物理占用的持久化适配,拥有者和成员生命周期由服务编排。 */ +@Repository +public class DispatchPhysicalClaimRepository { + private final DispatchPhysicalClaimMapper mapper; + /** 只经本域Mapper访问物理及其逻辑成员。 */ + public DispatchPhysicalClaimRepository(DispatchPhysicalClaimMapper mapper){this.mapper=mapper;} + /** 锁定唯一活动实托占用。 */ + public DispatchPhysicalPalletLock lockActive(long pallet){return mapper.lockActive(pallet);} + /** 锁定原物理占用记录。 */ + public DispatchPhysicalPalletLock lockOriginal(long id){return mapper.lockOriginal(id);} + /** 原引用集合只读物理身份供服务排序,空集合不生成无效IN。 */ + public List originals(List ids){return ids.isEmpty()?List.of():mapper.originals(ids);} + /** 按全局实托顺序锁本主单全部物理占用。 */ + public List lockDispatch(long dispatch){return mapper.lockDispatch(dispatch);} + /** 按全局实托顺序锁本草稿全部物理占用。 */ + public List lockDraft(String token,String operator){return mapper.lockDraft(token,operator);} + /** 活动实托唯一键承担并发领取的最终约束。 */ + public int insert(DispatchPhysicalPalletLock row){return mapper.insert(row);} + /** 到期只影响原物理行的草稿成员。 */ + public int expireMembers(long id,LocalDateTime at){return mapper.expireMembers(id,at);} + /** 只有没有活动成员时才结束原物理占用。 */ + public int releaseUnused(long id,String status,LocalDateTime at){return mapper.releaseUnused(id,status,at);} + /** 草稿整托迁移不修改其他拥有者。 */ + public int moveDraft(long id,String token,String operator,long previous,long next,LocalDateTime at){return mapper.moveDraft(id,token,operator,previous,next,at);} + /** 原草稿全部物理占用一起续期。 */ + public int renewDraft(String token,String operator,LocalDateTime at,LocalDateTime expires){return mapper.renewDraft(token,operator,at,expires);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java new file mode 100644 index 000000000..f32b74661 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 每张排车唯一作业,扫码明细保留真实客户与来源归属。 */ +@Repository +public class DispatchPickingRepository { + private final DispatchPdaTaskMapper tasks;private final DispatchPdaPickMapper picks; + private final InventoryPositionMapper positions;private final DispatchItemMapper items; + /** 封装原作业Mapper,库存状态变化仍经库存命令服务。 */ + public DispatchPickingRepository(DispatchPdaTaskMapper tasks,DispatchPdaPickMapper picks,InventoryPositionMapper positions,DispatchItemMapper items){this.tasks=tasks;this.picks=picks;this.positions=positions;this.items=items;} + /** 根锁后锁本车唯一作业。 */ + public DispatchPdaTask task(long dispatch){return tasks.lockByDispatch(dispatch);} + /** 审核后首次实际扫码创建本车作业。 */ + public int createTask(DispatchPdaTask task){return tasks.insert(task);} + /** 保存本车作业进度。 */ + public int saveTask(DispatchPdaTask task){return tasks.updateById(task);} + /** 普通读确定实际仓位身份,来源准备后再锁库存复验。 */ + public InventoryPosition position(long id){return positions.selectById(id);} + /** 真实扫码明细在同一事务保存。 */ + public int pick(DispatchPdaPick pick){return picks.insert(pick);} + /** 本明细实际已扫码数量。 */ + public long itemPicked(String customer,long task,long item){return picks.totalItem(customer,task,item);} + /** 自然收货后保存已经核实的同一计划来源实托身份。 */ + public int materialize(DispatchItem item){return items.updateById(item);} + /** 按真实客户取本车扫码,不借主单客户头。 */ + public List picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java new file mode 100644 index 000000000..c41eeec1e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; + +/** 原来源业务状态同步的数据封装,不能把物理库存SHIP再执行一次。 */ +@Repository +public class DispatchSourceDepartureRepository { + private final WarehouseTransferOrderMapper transfers; + /** 保留原转仓完成条件,主流程不重写该提供者SQL。 */ + public DispatchSourceDepartureRepository(WarehouseTransferOrderMapper transfers){this.transfers=transfers;} + /** 普通实际来源同步其转仓履约。 */ + public void transferOrder(String customer,long order,LocalDateTime at){transfers.markOutboundByOrder(customer,order,at);} + /** 换标只在该原转仓剩余可用箱数为零时完成。 */ + public void relabel(String customer,String number,LocalDateTime at){transfers.markOutboundByRelabel(customer,number,at);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java new file mode 100644 index 000000000..08d3261f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -0,0 +1,90 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */ +@Repository +public class DispatchSourceRepository { + private final DispatchCandidateMapper plans; + private final ReturnDispatchCandidateMapper returns; + private final DispatchSourceMapper source; + + /** 复用已有来源查询和本域锁Mapper。 */ + public DispatchSourceRepository(DispatchCandidateMapper plans, ReturnDispatchCandidateMapper returns, DispatchSourceMapper source) { + this.plans=plans; this.returns=returns; this.source=source; + } + /** 普通来源按仓和筛选在SQL取有界集合。 */ + public List availablePlans(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return plans.wholeAvailable(q.warehouseId(),null,q.planIds(),q.orderNo(),q.destinationWarehouseCode(),q.sourceStage(),q.draftToken()); + } + /** 退回来源保持其真实客户和库存仓。 */ + public List availableReturns(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return returns.wholeAvailable(q.warehouseId(),null,q.orderNo(),q.destinationWarehouseCode(),q.draftToken()); + } + /** 普通换标候选先限定真实仓,之后由实际测量提供者补齐证据。 */ + public List availableRelabel(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return plans.wholeRelabelAvailable(q.warehouseId(),null,q.orderNo(),null); + } + /** 完整组只扩大到已定位实托的可用普通及真实退回组成。 */ + public List physicalAvailable(String warehouse,List pallets) { + if(pallets.isEmpty())return List.of(); + var result=new java.util.ArrayList(); + var selected=source.physicalPlans(warehouse,pallets).stream().collect(java.util.stream.Collectors.groupingBy(r->r.customerId())); + for(var group:selected.entrySet())result.addAll(plans.readSelected(group.getKey(),group.getValue().stream().map(r->r.planPalletId()).toList())); + var returned=source.physicalReturns(warehouse,pallets).stream().collect(java.util.stream.Collectors.groupingBy(r->r.customerId())); + for(var group:returned.entrySet())result.addAll(returns.readSelected(group.getKey(),group.getValue().stream().map(r->r.receiptId()).toList())); + return result.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).toList(); + } + /** 原生退货箱的真实申请归属只在指定托盘码范围内读取。 */ + public List nativeApplications(String warehouse,List codes){return codes.isEmpty()?List.of():source.nativeApplications(warehouse,codes);} + /** 指定实托当前余额来源的真实订单集合。 */ + public List physicalOrders(String warehouse,List pallets){return pallets.isEmpty()?List.of():source.physicalOrders(warehouse,pallets);} + /** 同一全车各逻辑来源已经实际消费的箱数。 */ + public List pickedItems(long dispatch){return source.pickedItems(dispatch);} + /** 额外身份锁不隐式增加原本没有的资格条件。 */ + public Long lockOrderIdentity(String customer,long id){return source.lockOrderIdentity(customer,id);} + /** 来源托盘的真实客户。 */ + public List planOwners(Long id) { return plans.findCustomerScopesByPlanPalletIds(List.of(id)); } + /** 退回收据的真实客户。 */ + public List returnOwners(Long id) { return returns.customers(List.of(id)); } + /** 换标单业务客户。 */ + public String relabelOwner(String no) { return source.relabelOwner(no); } + /** 真实申请轮次依据。 */ + public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); } + /** 取得已有普通计划候选,保留原来源资格SQL。 */ + public List selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } + /** 取得已有真实退回候选。 */ + public List selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } + /** 同托真实来源订单组成。 */ + public List sourceOrders(String customer, Long pallet, Long planPallet) { return source.sourceOrders(customer,pallet,planPallet); } + /** 来源订单加锁并校验未暂扣。 */ + public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); } + /** 锁定真实计划托盘根。 */ + public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); } + /** 锁定实际退回收据根。 */ + public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); } + /** 真实库存数量及版本。 */ + public List stock(String customer, Long pallet, boolean lock) { return source.stock(customer,pallet,lock); } + /** 不可变收货来源组成。 */ + public List receipts(String customer, Long pallet) { return source.receipts(customer,pallet); } + /** 同一实托的退回实收不得从来源证明丢失。 */ + public List returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);} + /** 释放已到期草稿键。 */ + public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); } + /** 锁定当前活动资源占用。 */ + public List claims(String customer, String key, LocalDateTime at) { return source.claims(customer,key,at); } + /** 精确释放本车本来源,不释放他车重新取得的键。 */ + public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getSourceKey(),at);} + /** 原计划收货的物理身份与其活动键同事务补齐,不复制来源占用。 */ + public int materialize(long dispatch,String customer,long plan,long pallet,long physical,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,physical,at);} + /** 保存正式源/实托占用。 */ + public int claim(long id, String customer, String key, Long plan, Long pallet, Long receipt, Long physical, long dispatch, String operator, LocalDateTime at) { + return source.claim(id,customer,key,plan,pallet,receipt,physical,dispatch,operator,at); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java new file mode 100644 index 000000000..696ab1a24 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -0,0 +1,852 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ +@Service +public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; + @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; + @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins; + /** 仅用于显示的不可变来源字段,数量/归属仍取关系明细和真实拣货。 */ + public record SourceDisplay(String containerNo,String planNo) { } + private static final String PREFIX = "platform:overseas-warehouse:dispatch:"; + private final DispatchAggregateRepository repository; + private final DispatchSourceRepository sources; + private final DispatchService sourceRules; + private final FleetTripExecutionService fleet; + private final DispatchCommandLedger commands; + private final DispatchCommandHasher hashes; + private final ObjectMapper json; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads; + + /** 领域编排只访问Repository和提供者应用入口。 */ + public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources, + DispatchService sourceRules, FleetTripExecutionService fleet, DispatchCommandLedger commands, + DispatchCommandHasher hashes, ObjectMapper json) { + this.repository=repository; this.sources=sources; this.sourceRules=sourceRules; this.fleet=fleet; + this.commands=commands; this.hashes=hashes; this.json=json; + } + + /** 草稿转换统一预锁全批资源;正式create只重入已持身份锁,再逐组持久新根。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDraftCreationResources(Actor actor,List creates) { + requirePlatform(actor);if(creates==null || creates.isEmpty() || creates.size()>1000 || creates.stream().anyMatch(Objects::isNull))throw bad("请选择完整草稿建车分组"); + fleet.lockDispatchCreationResources(creates.stream().map(c->fleetVehicle(c.vehicle())).toList(),creates.stream().map(c->fleetDriver(c.driver())).toList()); + appointments.lockCreates(creates); + } + + /** 全范围先锁父级/资源,再按真实主单ID锁全部dispatch,不交错取下一车父锁。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public void lockExistingAppointmentDispatches(Actor actor,List numbers) { + requirePlatform(actor);fleet.lockExistingDispatchShells(numbers); + var observed=repository.findMany(numbers); + if(observed.size()!=numbers.size())throw conflict("原运输排车集合已变化,请重新读取完整范围"); + for(var row:observed.stream().sorted(Comparator.comparing(DispatchAggregate::getId)).toList()) { + var locked=required(actor,row.getDispatchNo(),true);var identity=fleet.dispatchIdentity(row.getDispatchNo()); + if(!Objects.equals(locked.getTripId(),row.getTripId()) || identity.tripId()!=locked.getTripId())throw conflict("原运输资源归属已变化"); + } + } + + /** 原预约父事务的唯一改量桥接,不调用旧Fleet解绑,不接收用户可选的移货开关。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public CommandResult applyExistingAppointmentChange(Actor actor,String number,DispatchCommands.ReasonCommand request, + List physical,List expected,List next,boolean remove) { + requirePlatform(actor);var row=required(actor,number,true); + if(request==null || trim(request.idempotencyKey())==null || trim(request.reason())==null)throw bad("原预约命令身份及变更原因必填"); + String key="APPOINTMENT:"+hashes.source(Arrays.asList(actor.operatorId(),request.idempotencyKey(),number)); + var identity=identity(actor,number,Action.EDIT,request.recordVersion(),key,Arrays.asList("EXISTING_APPOINTMENT",request,physical,expected,next,remove)); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + requireEditing(row,request.recordVersion()); + if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有实际扫码或有效下游单据,不能普通调整原预约"); + if(physical==null || physical.isEmpty() || physical.size()>500 || physical.stream().anyMatch(Objects::isNull))throw bad("原预约缺少完整真实实物范围"); + var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); + var ids=new HashSet();var touched=new ArrayList(); + for(var input:physical) { + var item=byId.get(input.itemId()); + if(item==null || !ids.add(input.itemId()) || !Objects.equals(input.customerId(),item.getCustomerId()) + || !Objects.equals(input.planPalletId(),item.getPlanPalletId()) || !Objects.equals(input.sourceStage(),item.getSourceStage()) + || !Objects.equals(input.packages(),item.getPackageCount()) || input.packages()==null || input.packages()<=0 + || !Objects.equals(item.getOverseasWarehouseId(),row.getOverseasWarehouseId()))throw conflict("原预约只能调整已核齐真实客户与完整数量的原实物"); + touched.add(item); + } + var current=repository.batches(row.getId()).stream().filter(b->ids.contains(b.dispatchItemId())).toList(); + var actual=current.stream().map(b->new BatchAllocation(b.planPalletId(),b.batchKey(),b.customerId(),b.sourceKey(),b.destinationKey(),b.packageCount(),b.recordVersion())).toList(); + validateAppointmentVersions(expected);validateAppointmentVersions(next); + if(!allocationTotals(actual).equals(allocationTotals(expected)))throw conflict("原实物预约分配集合或数量已变化,请重新读取完整范围"); + if(remove) { + if(next!=null && !next.isEmpty())throw bad("移出完整实物时不能保留其新分配"); + var relabels=touched.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet()); + if(items.stream().anyMatch(i->relabels.contains(i.getSourceBusinessNo()) && !ids.contains(i.getId())))throw conflict("换标必须按原完整申请调整,不能仅移出部分来源"); + var pallets=touched.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toSet()); + if(items.stream().anyMatch(i->pallets.contains(i.getWarehousePalletId()) && !ids.contains(i.getId())))throw conflict("同实托完整来源须一起调整,不能仅移出部分成员"); + lockExecutionSources(row,items); + appointments.remove(row.getId(),touched);sourceRules.releaseWholeRelabel(touched); + releaseSources(row,actor,touched,now()); + for(var item:touched){relabelSources.removeUnpicked(row.getId(),item.getId());if(repository.removeItem(item.getId())!=1)throw conflict("原货物明细已变化");} + refreshCargo(row,items.stream().filter(i->!ids.contains(i.getId())).toList());row=repository.find(number); + history(row,actor,Action.EDIT,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); + commands.append(row.getId(),actor,identity,result);return result; + } + appointments.bind(row.getId(),touched,next);invalidate(row,"原预约分配已调整,请重新两级审核"); + return saveCommand(row,actor,identity,request.reason(),new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); + } + + /** 原分配比较业务归属和数量;父事务已锁批次并可能正推进权威版本。 */ + private record AllocationIdentity(Long planPalletId,String batchKey,String customerId,String sourceKey,String destinationKey) { } + private static Map allocationTotals(List allocations) { + var result=new HashMap(); + if(allocations!=null)for(var a:allocations) { + if(a==null || trim(a.batchKey())==null || trim(a.customerId())==null || trim(a.sourceKey())==null || trim(a.destinationKey())==null || a.packageCount()<=0)throw bad("原预约分配缺少真实来源或数量"); + result.merge(new AllocationIdentity(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey()),(long)a.packageCount(),Math::addExact); + } + return result; + } + private static void validateAppointmentVersions(List allocations) { + var versions=new HashMap(); + if(allocations!=null)for(var a:allocations) { + if(a==null || a.recordVersion()==null || a.recordVersion()<0)throw bad("原预约批次版本必填且不能为负"); + var old=versions.putIfAbsent(a.batchKey(),a.recordVersion());if(old!=null && !old.equals(a.recordVersion()))throw conflict("同一预约批次包含不同原版本"); + } + } + + /** 候选及来源摘要来自同一只读快照;公开选择可直接用于同仓多客户建单。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchPlanningModels.CandidateList candidates(Actor actor,DispatchPlanningModels.CandidateQuery query) { + requirePlatform(actor); + if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓"); + if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法"); + var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), + trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken())); + var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized); + var groups=sourceRules.selectionGroups(rows); + var batchFacts=appointments.candidates(rows); + var result=new ArrayList(); + for(var row:rows) { + SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL: + row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN; + String relabel=type==SourceType.RELABEL?row.orderRefs():null; + var preliminary=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),null); + String key=DispatchService.wholeSourceKey(preliminary);var group=groups.get(key); + if(group==null)throw conflict("完整来源组缺失,请重新读取"); + String version=group.sources().stream().filter(s->s.sourceKey().equals(key)).findFirst().orElseThrow(()->conflict("本来源不在完整组中")).selection().sourceVersion(); + var selection=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),version); + var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(), + row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(), + row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone()); + var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId())); + var references=new ArrayList();var reasons=new LinkedHashSet(); + reasons.addAll(group.blockReasons()); + if(fact!=null) { + reasons.addAll(fact.blockReasons()); + // 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。 + if(fact.occupied() && normalized.draftToken()==null)reasons.add("实物已被其他有效运输安排占用"); + for(var ref:fact.appointmentBatches()) { + if(!Objects.equals(ref.customerId(),row.customerId()))throw conflict("原预约候选客户与实物归属不一致"); + references.add(new BatchReference(ref.batchKey(),ref.batchNo(),ref.customerId(),ref.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),ref.recordVersion())); + } + } + result.add(new DispatchPlanningModels.Candidate(selection,key,version,row.customerId(),row.customerName(),row.overseasWarehouseId(), + row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(), + destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group)); + } + return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true); + } + + /** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchWorkModels.Items items(Actor actor,String number) { + requirePlatform(actor);var row=required(actor,number,false); + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var gate=actions(actor,row).get(Action.REMOVE_CARGO);var result=new ArrayList(); + for(var item:repository.cargo(row.getId())) { + boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType()); + var remove=relabel?new Gate(false,"RELABEL_WHOLE_SOURCE","换标按整单排车,请取消整单后重排"):gate; + if(picked.getOrDefault(item.getId(),0L)>0)remove=new Gate(false,"ALREADY_PICKED","本来源已经真实扫码"); + result.add(new DispatchWorkModels.CargoItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getSourceVersion(), + relabel?SourceType.RELABEL:item.getReturnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN,item.getPlanPalletId(),item.getReturnReceiptId(), + relabel?item.getSourceBusinessNo():null,item.getWarehousePalletId(),item.getPalletNo(),item.getOrderNo(),item.getInboundCode(),destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),remove)); + } + return new DispatchWorkModels.Items(number,row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),List.copyOf(result)); + } + + /** 路线返回真实资源记录中的顺序,索引可直接对应装载卸货站。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchWorkModels.Route route(Actor actor,String number) { + requirePlatform(actor);var row=required(actor,number,false);var actual=fleet.dispatchStops(number);var stops=new ArrayList(); + for(int index=0;index changed=addResolved(row,actor,request.cargo(),request.batchAllocations()); + row=repository.find(number); + history(row,actor,Action.CREATE,request.remark()); + var result=result(row,identity,new DispatchResults.Saved(number,changed)); + commands.append(id,actor,identity,result); + return result; + } + + /** 先trip→资源→dispatch,原命令恢复后才判断当前版本。 */ + @Transactional(isolation = Isolation.READ_COMMITTED) + public CommandResult addCargo(Actor actor, String number, DispatchCommands.AddCargo request) { + requirePlatform(actor); + var shell=fleet.lockDispatchShell(number); + var row=required(actor,number,true); + if (!Objects.equals(row.getTripId(),shell.tripId())) throw conflict("整车资源关系已变化"); + var identity=new CommandIdentity(Action.ADD_CARGO,request.idempotencyKey(),request.recordVersion(), + hashes.command(actor,Action.ADD_CARGO,number,request.recordVersion(),request)); + var replay=commands.replay(actor,number,identity); + if (replay.isPresent()) return replay.get(); + requireEditing(row,request.recordVersion()); + appointments.lock(repository.batches(row.getId()),request.batchAllocations()); + if (request.cargo()==null || request.cargo().isEmpty()) throw bad("请选择需要加入的真实货物"); + var changed=addResolved(row,actor,request.cargo(),request.batchAllocations()); + row=repository.find(number); history(row,actor,Action.ADD_CARGO,null); + var result=result(row,identity,new DispatchResults.Saved(number,changed)); + commands.append(row.getId(),actor,identity,result); + return result; + } + + /** 移除真实未执行明细,来源占用与货量更新一起提交,保留既有整单换标边界。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult removeCargo(Actor actor,String number,long itemId,DispatchCommands.ReasonCommand request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.REMOVE_CARGO,request.recordVersion(),request.idempotencyKey(),Arrays.asList(itemId,request)); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); + if(repository.realPicked(row.getId())!=0)throw conflict("本车已经真实扫码,不能移除货物"); + var all=repository.cargo(row.getId());var selected=all.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"排车货物明细不存在")); + if("RELABEL_ORDER".equals(selected.getSourceBusinessType()))throw conflict("换标按整单排车,请取消整单后重排"); + if(selected.getWarehousePalletId()!=null && all.stream().anyMatch(i->!Objects.equals(i.getId(),selected.getId()) && Objects.equals(i.getWarehousePalletId(),selected.getWarehousePalletId())))throw conflict("本实托含多个真实来源,请按完整实托调整或取消后重排"); + lockExecutionSources(row,all); + appointments.lock(repository.batches(row.getId()),List.of()); + appointments.remove(row.getId(),List.of(selected)); + releaseSources(row,actor,List.of(selected),now()); + if(repository.removeItem(itemId)!=1)throw conflict("货物明细已变化"); + refreshCargo(row,all.stream().filter(i->!Objects.equals(i.getId(),itemId)).toList());row=repository.find(number); + history(row,actor,Action.REMOVE_CARGO,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,List.of(itemId))); + commands.append(row.getId(),actor,identity,result);return result; + } + + /** 零扫码取消协调主单、原来源与资源,取消保留在正交status而不增加第六阶段。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult cancel(Actor actor,String number,DispatchCommands.ReasonCommand request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); + if(trim(request.reason())==null)throw bad("取消原因不能为空"); + if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消"); + var items=repository.cargo(row.getId());lockExecutionSources(row,items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); + releaseSources(row,actor,items,now()); + fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消"); + return saveCommand(row,actor,identity,request.reason()); + } + + /** 旧新资源身份一次锁齐,编辑关键依据后两级审核均失效,普通备注不篡改旧审核。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult update(Actor actor,String number,DispatchCommands.Update request) { + requirePlatform(actor); + fleet.lockDispatchShell(number,request.vehicle()==null?null:request.vehicle().vehicleId(),request.driver()==null?null:request.driver().driverId()); + var row=required(actor,number,true);var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + String before=basis(row); + var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); + row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); + row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); + if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); + fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark()); + return saveCommand(row,actor,identity,request.remark()); + } + + /** 提交新轮次审核,不把已通过调度审核复用到更改后的货物或资源。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult submitReview(Actor actor,String number,DispatchCommands.SubmitReview request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); + verifyReviewResources(row); + if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); + long round=row.getReviewRound()+1;String basis=basis(row); + row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); + row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null))); + row.setLoadingReviewJson(write(pending(round))); + return saveCommand(row,actor,identity,request.remark()); + } + + /** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) { + var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW; + var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + if(request.decision()==null)throw bad("请选择审核结论"); + String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name(); + if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化"); + if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空"); + if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交"); + if(loading) { + var dispatch=reviews(row).dispatchReview(); + if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效"); + } + if(request.decision()==Decision.APPROVED)verifyReviewResources(row); + var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null); + if(loading) { + row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); + if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name()); + } else { + row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); + row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound()))); + } + return saveCommand(row,actor,identity,request.reason()); + } + + /** 只允许零真实扫码退回原单,保留编号并明确作废两级审核。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult returnToReview(Actor actor,String number,DispatchCommands.ReturnToReview request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.RETURN_TO_REVIEW,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + if(row.getRecordVersion().longValue()!=request.recordVersion() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()))throw conflict("仅当前版本装车中的有效单可退回重审"); + if(trim(request.reason())==null)throw bad("退回原因不能为空"); + if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前"); + row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason()); + return saveCommand(row,actor,identity,request.reason()); + } + + /** 派送域只读取本车真实已拣归属;缺失归属不会由计划补齐。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Context preview(Actor actor,String number) { return context(actor,required(actor,number,false)); } + + /** 原命令恢复前仅取根身份锁,不提前拒绝旧版本或已经发车的阶段。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public Context lockForArrangement(Actor actor,String number) { + required(actor,number,false);fleet.lockDispatchShell(number);return context(actor,required(actor,number,true)); + } + + /** 新执行命令在原根锁和重放检查之后,锁齐全车真实组成并核对冻结来源。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void lockExecutionSources(DispatchAggregate row,List items) { + relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); + } + + /** 当前范围复验后只恢复原主体与严格摘要相同的命令。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public Optional replay(Context context,Actor actor,CommandIdentity identity) { + required(actor,context.dispatchNo(),false);return commands.replay(actor,context.dispatchNo(),identity); + } + + /** 新安排只能使用装车后的完整真实来源。 */ + @Override public void requireFreshArrangement(Context context,long version) { + if(context.recordVersion()!=version || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING + || !context.pickingReconciled() || context.loadingCompletionHash()==null || context.loadingCompletedAt()==null) + throw conflict("本车尚未完成真实拣货及装车,或当前版本已变化"); + } + + /** 子单及费用保存后,同一事务以主单CAS和全局账本确认原子结果。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public CommandResult recordArrangement(Context context,Actor actor,CommandIdentity identity,DispatchDeliveryModels.ArrangementFact fact,boolean existing) { + var row=required(actor,context.dispatchNo(),true); + if(row.getRecordVersion()!=identity.recordVersion() || !Objects.equals(row.getCargoHash(),fact.cargoHash()) + || !Objects.equals(row.getLoadingCompletionHash(),fact.loadingCompletionHash()) || fact.result()==null)throw conflict("本车安排依据已变化"); + if(existing) { + if(!Objects.equals(row.getArrangementKey(),fact.arrangementKey()) || !Objects.equals(row.getArrangementBusinessHash(),fact.arrangementBusinessHash()))throw conflict("原派送安排依据不一致"); + } else { + requireFreshArrangement(context,identity.recordVersion());if(row.getArrangementKey()!=null)throw conflict("本车已经安排派送"); + row.setArrangementKey(fact.arrangementKey());row.setArrangementBusinessHash(fact.arrangementBusinessHash()); + } + long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(now()); + if(repository.saveState(row,version)!=1)throw conflict("排车版本已变化");history(row,actor,Action.ARRANGE_DELIVERY,null); + var result=new CommandResult(row.getDispatchNo(),Action.ARRANGE_DELIVERY,identity.idempotencyKey(),row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()), + Instant.now().truncatedTo(ChronoUnit.MILLIS),existing?Effect.EXISTING:Effect.APPLIED,counts(row),fact.result(),false,existing); + commands.append(row.getId(),actor,identity,result);return result; + } + + /** 两端读取同一实载清单;已知实测自动带出,建议值不能填入实际字段。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchPlanningModels.LoadingPreview loading(Actor actor,String number) { + var row=required(actor,number,false);var context=context(actor,row);var items=repository.cargo(row.getId()); + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var measured=executionReads.actualMeasurements(row.getId()).stream().collect(Collectors.groupingBy(p->p.palletId())); + var stops=fleet.dispatchStops(number);var pallets=new ArrayList();var missing=new ArrayList(); + var groups=items.stream().filter(i->i.getWarehousePalletId()!=null).collect(Collectors.groupingBy(DispatchItem::getWarehousePalletId,TreeMap::new,Collectors.toList())); + for(var entry:groups.entrySet()) { + var group=entry.getValue();var first=group.get(0);String destination=destination(first).key(); + if(group.stream().anyMatch(i->!Objects.equals(destination,destination(i).key())))throw conflict("同一实托的目的地不一致,请先核对来源"); + Integer index=null;for(int n=1;n(); + var facts=measured.getOrDefault(entry.getKey(),List.of()); + if(facts.size()==1) { + var fact=facts.get(0);weight=fact.weightKg();volume=fact.volumeCbm(); + if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true)); + if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true)); + } + var absent=new ArrayList();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex"); + missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList()); + pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(), + group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof))); + } + if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源"); + String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets)); + var gate=actions(actor,row).get(Action.LOADING_COMPLETE); + if(!context.pickingReconciled() || groups.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))gate=new Gate(false,"PICKING_INCOMPLETE","本车真实拣货和逐项来源尚未核平"); + return new DispatchPlanningModels.LoadingPreview(number,row.getRecordVersion(),context.status(),context.stage(),hash,context.pickingReconciled(),context.resources(), + List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing)); + } + + /** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) { + required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true); + var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车"); + lockExecutionSources(row,repository.cargo(row.getId())); + var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); + if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); + if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量"); + var supplied=new HashMap(); + for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复"); + for(var p:preview.pallets()) { + var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex() + || p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对"); + } + verifyReviewResources(row);var context=context(actor,row); + var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList(); + var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList(); + fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark()); + var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at); + row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name()); + return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed)); + } + + /** 当前明细、实际扫码、真实归属及批次组合为唯一整车上下文。 */ + /** Fleet附加能力只读执行事实;调用方已按trip→资源→主单顺序持父锁。 */ + @Transactional(propagation=Propagation.MANDATORY) + public Context fleetContext(String number,boolean lock) { + var row=lock?repository.lock(number):repository.find(number); + if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + return context(null,row); + } + private Context context(Actor actor,DispatchAggregate row) { + var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化"); + var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); + var batches=repository.batches(row.getId());var cargo=new ArrayList();var actualSources=executionReads.sources(row.getId()); + var origins=actualSources.stream().anyMatch(p->p.dispatchItemSourceId()!=null)?sourceOrigins.all(row.getId()).stream().collect(Collectors.toMap(s->s.getId(),s->s)):Map.of(); + var remaining=new LinkedHashMap();batches.forEach(b->remaining.put(b,(long)b.packageCount())); + for(var pick:actualSources) { + var item=byId.get(pick.itemId());String source=pick.returnCartonId()!=null?"RETURN_CARTON:"+pick.returnCartonId():pick.returnReceiptId()!=null?"RETURN:"+pick.returnReceiptId():"ORDER:"+pick.orderId(); + var allocations=new ArrayList();long unassigned=pick.packages(); + for(var batch:batches)if(Objects.equals(batch.dispatchItemId(),item.getId()) && Objects.equals(batch.customerId(),item.getCustomerId()) && Objects.equals(batch.sourceKey(),source) && unassigned>0) { + long allocated=Math.min(unassigned,remaining.get(batch));if(allocated<=0)continue; + allocations.add(new BatchAllocation(batch.planPalletId(),batch.batchKey(),batch.customerId(),batch.sourceKey(),batch.destinationKey(),Math.toIntExact(allocated),batch.recordVersion())); + unassigned-=allocated;remaining.put(batch,remaining.get(batch)-allocated); + } + var origin=pick.dispatchItemSourceId()==null?null:origins.get(pick.dispatchItemSourceId()); + var sourceOrigin=origin==null?null:new SourceOrigin(origin.getId(),origin.getRelabelApplicationId(),origin.getRelabelItemId(),origin.getSourceInventoryBalanceId(),origin.getSourceInboundOrderId(),origin.getSourceInboundOrderNo(),origin.getSourceInboundCode(),origin.getProductQuantity()!=null || pick.productQuantity()>0?pick.productQuantity():null); + cargo.add(new CargoLine(item.getId(),pick.customerId(),item.getCustomerName(),pick.orderId(),pick.orderNo(),source,item.getSourceStage(),pick.sourceRegion(),pick.sourceInboundId(),pick.inboundCode(), + item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),pick.cartonMark(), + "RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations,sourceOrigin)); + } + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())) + && executionReads.inconsistentPicks(row.getId())==0 && cargo.stream().mapToLong(CargoLine::packages).sum()==n(row.getPackageCount()); + return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),identity.tripNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(), + Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),row.getLoadingCompletionHash(),instant(row.getLoadingCompletedAt()),instant(row.getDepartedAt()), + read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),complete,List.copyOf(cargo)); + } + + /** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */ + private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); } + /** 严格请求身份不接收前端摘要或操作者。 */ + private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));} + /** 每次状态变更、历史及不可变命令结果同事务保存。 */ + private CommandResult saveCommand(DispatchAggregate row,Actor actor,CommandIdentity identity,String reason) { + return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(row.getDispatchNo(),List.of())); + } + /** 公开结果类型按动作保存,不把Loaded等恢复成普通保存结果。 */ + private CommandResult saveCommand(DispatchAggregate row,Actor actor,CommandIdentity identity,String reason,DispatchResults.Payload payload) { + long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(now()); + if(repository.saveState(row,version)!=1)throw conflict("排车版本已变化,请重新核对"); + history(row,actor,identity.action(),reason);var result=result(row,identity,payload); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */ + private String basis(DispatchAggregate row) { + var facts=new TreeMap();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); + facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts); + } + /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ + private void invalidate(DispatchAggregate row,String reason) { + row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); + var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason); + row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid)); + } + /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ + void verifyReviewResources(DispatchAggregate row) { + var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); + if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话"); + var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积"); + var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间"); + if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期"); + if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds()); + if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds()); + var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()), + new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); + if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); + } + /** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */ + private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List ids) { + if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请补齐本车有效的车辆及司机证据"); + for(var id:ids)evidence.metadata(dispatch,kind,id); + } + + /** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.Page page(Actor actor, DispatchViews.Query query) { + requirePlatform(actor); + long page=query.page()==0 ? 1 : query.page(),size=query.size()==0 ? 20 : query.size(); + if(page<1 || size<1 || size>200 || page>1_000_000) throw bad("分页范围不合法"); + StatusFilter status=query.status()==null ? StatusFilter.ACTIVE : query.status(); + if(query.beforeLoadingSection()!=null && (status!=StatusFilter.ACTIVE || query.stage()!=Stage.BEFORE_LOADING)) throw bad("审核分区仅适用于装车前有效单据"); + if(query.reviewStatus()!=null && (query.beforeLoadingSection()==null || query.beforeLoadingSection()==BeforeLoadingSection.VEHICLE_INFO)) throw bad("请先选择对应审核分区"); + var filter=new DispatchAggregateMapper.Search(trim(query.dispatchNo()),trim(query.vehiclePlate()),trim(query.warehouseId()), + trim(query.customerId()),trim(query.fbaAppointmentNo()),status.name(),query.stage()==null?null:query.stage().name(), + query.beforeLoadingSection()==null?null:query.beforeLoadingSection().name(),query.reviewStatus()==null?null:query.reviewStatus().name(),(page-1)*size,(int)size); + var rows=repository.page(filter); long count=repository.count(filter); + return new DispatchViews.Page(rows.stream().map(r -> listRow(actor,r)).toList(),count,page,size,(count+size-1)/size, + statistics(repository.summaries(filter)),Instant.now()); + } + + /** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.Detail detail(Actor actor, String number) { + var row=required(actor,number,false); var cargo=repository.cargo(row.getId()); + var resources=read(row.getResourcesJson(),Resources.class); + var batches=repository.batches(row.getId()); + var references=new LinkedHashMap>(); + batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b)); + var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(), + group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList(); + var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap( + b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream() + .map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList(); + var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(), + d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), + d.status(),d.packageCount(),d.palletCount())).toList(); + var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); + return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), + new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs), + documents,orderDetails(cargo)); + } + + /** 恢复动作结果前重新核验当前单据范围和原动作权限。 */ + @Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED) + public CommandResult command(Actor actor,String number,String key) { + required(actor,number,false); + var allowed=EnumSet.noneOf(Action.class); + for(var action:Action.values()) if(hasPermission(actor,action)) allowed.add(action); + return commands.readOriginal(actor,number,key,allowed); + } + + /** 全部客户来源和占用同事务,任一失败不留下部分整车。 */ + private List addResolved(DispatchAggregate row,Actor actor,List selections,List batchAllocations) { + if(selections==null || selections.isEmpty()) { + if(batchAllocations!=null && !batchAllocations.isEmpty())throw bad("没有真实货物时不能提交预约分配"); + return List.of(); + } + var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet()); + var resolved=current.isEmpty()?sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections):sourceRules.lockAdditionalSources(row.getOverseasWarehouseId(),selections,current); + if(!current.isEmpty())lockExecutionSources(row,current); + var at=now();var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null); + var physical=physicalClaims.reserve(owner,java.util.stream.Stream.concat(current.stream().map(DispatchItem::getWarehousePalletId),resolved.stream().map(s->s.candidate().warehousePalletId())).filter(Objects::nonNull).toList(),at); + var saved=new ArrayList(); + for(var source:resolved) { + if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入"); + var c=source.candidate(); + sources.expire(c.customerId(),source.sourceKey(),at); + if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用"); + try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");} + catch(DuplicateKeyException error){throw conflict("所选逻辑来源已被并发排车占用,请重新核对");} + var item=item(row,source); if(repository.insertItem(item)!=1) throw conflict("排车明细未保存"); + saved.add(item); + } + var all=new ArrayList<>(current);all.addAll(saved); + if(saved.stream().anyMatch(i->"RELABEL".equals(i.getSourceStage()) && "RELABEL_ORDER".equals(i.getSourceBusinessType())))relabelSources.capture(row,all,saved,at); + sourceRules.bindWholeRelabel(resolved,saved); + appointments.bind(row.getId(),saved,batchAllocations); + refreshCargo(row,all);return saved.stream().map(DispatchItem::getId).toList(); + } + + /** 先锁原物理拥有者,再逐逻辑键释放;同托仍有活动成员时物理排他继续保留。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void releaseSources(DispatchAggregate row,Actor actor,List items,LocalDateTime at) { + var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null); + var original=physicalClaims.lockOwner(owner); + for(var item:items)if(sources.release(row.getId(),item,at)!=1)throw conflict("原逻辑来源占用已变化"); + physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at); + } + + /** 加货和移货共享同一货量、摘要、审核失效与Fleet容量刷新。 */ + private void refreshCargo(DispatchAggregate row,List all) { + int packages=0; + try { for(var item:all) packages=Math.addExact(packages,item.getPackageCount()); } + catch(ArithmeticException error) { throw bad("本车箱数超过可支持范围"); } + int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count(); + String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")); + var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList(); + if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物"); + var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key())) + .collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops); + } + + /** 冻结真实来源和客户,候选订单号只用于展示,不替代后续逐来源拣货归属。 */ + private DispatchItem item(DispatchAggregate row,DispatchService.WholeSource source) { + var c=source.candidate(); var i=new DispatchItem(); i.setId(IdWorker.getId()); i.setDispatchOrderId(row.getId()); + i.setCustomerId(c.customerId()); i.setCustomerName(c.customerName()); i.setSourceKey(source.sourceKey()); i.setSourceVersion(source.sourceVersion()); i.setSourceSnapshotJson(write(new SourceDisplay(c.containerNo(),c.planNo()))); + i.setPlanId(c.planId()); i.setPlanPalletId(c.planPalletId()); i.setReturnReceiptId(c.returnReceiptId()); i.setWarehousePalletId(c.warehousePalletId()); + i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setOrderRefs(c.orderRefs()); i.setInboundCode(c.inboundCode()); i.setPalletNo(c.palletNo()); + i.setOverseasWarehouseId(c.overseasWarehouseId()); i.setOverseasWarehouseName(c.overseasWarehouseName()); + i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode()); i.setDestinationWarehouseName(c.destinationWarehouseName()); + i.setFbaWarehouseCode(c.fbaWarehouseCode()); i.setDestinationType(c.destinationType()); i.setDeliveryAddress(c.deliveryAddress()); i.setPostalCode(c.postalCode()); i.setContactName(c.contactName()); i.setContactPhone(c.contactPhone()); + i.setPackageCount(c.packageCount()); i.setGrossWeightKg(c.grossWeightKg()); i.setVolumeWeightKg(c.volumeWeightKg()); i.setVolumeCbm(c.volumeCbm()); + i.setSourceStage(source.selection().sourceType()==SourceType.RELABEL ? c.returnCartonSource()?"RETURN_RELABEL":"RELABEL" : c.sourceStage()); + i.setCartonMarks(c.relabelCartonMarks()==null?c.cartonMarks():c.relabelCartonMarks()); + if(source.selection().relabelNo()!=null) { i.setSourceBusinessType("RELABEL_ORDER"); i.setSourceBusinessNo(source.selection().relabelNo()); } + i.setCreatedAt(now()); return i; + } + + + /** 读取后验证PDA绑定仓;平台整车读取不按客户头部裁切。 */ + private DispatchAggregate required(Actor actor,String number,boolean lock) { + if(actor==null || actor.operatorId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"请先登录"); + var row=lock?repository.lock(number):repository.find(number); + if(row==null) throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),row.getOverseasWarehouseId())) throw new BizException(ResultCode.FORBIDDEN,"排车单不属于当前绑定仓"); + return row; + } + + /** 普通变更不允许覆盖已执行的真实库存或旧版本。 */ + private static void requireEditing(DispatchAggregate row,long version) { + if(row.getRecordVersion()!=version) throw conflict("排车版本已变化,请重新核对"); + if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物"); + } + + /** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */ + Map actions(Actor actor,DispatchAggregate row) { + var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); + Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); + for(var action:Action.values()) { + boolean state=switch(action) { + case CREATE -> actor.mode()==ActorMode.PLATFORM; + case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING; + case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING; + case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0; + case PICK -> active && stage==Stage.LOADING && review==ReviewState.APPROVED; + case LOADING_COMPLETE -> active && stage==Stage.LOADING && reconciled(row); + case ARRANGE_DELIVERY -> active && stage==Stage.AFTER_LOADING; + case DEPART -> active && stage==Stage.AFTER_LOADING && row.getArrangementKey()!=null; + }; + boolean permitted=hasPermission(actor,action); + gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件")); + } + return Collections.unmodifiableMap(gates); + } + + /** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */ + private static boolean hasPermission(Actor actor,Action action) { + var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false; + if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action) + && auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority())); + if(action==Action.PICK) return false; + String permission=switch(action) { + case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel"; + case DISPATCH_REVIEW -> "platform:fleet:trip:dispatch-review"; case LOADING_REVIEW -> "platform:fleet:trip:loading-review"; + case LOADING_COMPLETE -> PREFIX+"loading-complete"; case DEPART -> PREFIX+"depart"; + case ARRANGE_DELIVERY -> PREFIX+"transfer-delivery"; default -> PREFIX+"edit"; + }; + return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority())); + } + + /** 统计按真整车去重并补齐全部零项。 */ + private static DispatchViews.Statistics statistics(List groups) { + var stages=new EnumMap(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L); + long active=0,cancelled=0,before=0; long[] dispatch=new long[4],loading=new long[4]; + for(var group:groups) { + if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; } + active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum); + if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState()); + dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity(); + loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); } + } + return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before, + new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3]))); + } + + /** 列表不逐行读取明细、附件或派送。 */ + private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) { + return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row)); + } + + /** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */ + private List orderDetails(List cargo) { + var result=new ArrayList(); + for(var item:cargo) { + var source=read(item.getSourceSnapshotJson(),SourceDisplay.class); + var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList(); + if(orders.isEmpty()) orders=Collections.singletonList(null); + for(var order:orders) result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),order,item.getInboundCode(),orders.size()==1?item.getGrossWeightKg():null, + orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo())); + } + return List.copyOf(result); + } + + /** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */ + private static Resources resources(FleetExecutionModels.DispatchShell shell) { + var v=shell.vehicle(); var d=shell.driver(); + return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(), + new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(), + new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt()))); + } + + /** 主档选择不接受请求覆盖其车牌和容量。 */ + private static FleetExecutionModels.DispatchVehicle fleetVehicle(DispatchCommands.Vehicle v) { + if(v==null) return null; if(v.mode()==null) throw bad("请选择车辆来源方式"); + return new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),v.capacity()==null?null:v.capacity().palletSpaces(), + v.capacity()==null?null:v.capacity().maxLoadKg(),v.capacity()==null?null:v.capacity().maxVolumeCbm(),v.evidenceFileIds()==null?List.of():List.copyOf(v.evidenceFileIds())); + } + + /** 司机证据是本趟输入,主档事实仍由Fleet提供者核验。 */ + private static FleetExecutionModels.DispatchDriver fleetDriver(DispatchCommands.Driver d) { + if(d==null) return null; if(d.mode()==null) throw bad("请选择司机来源方式"); var e=d.evidence(); + return new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e==null?null:e.licenseNo(),e==null?null:e.licenseExpiresOn(), + e==null||e.fileIds()==null?List.of():List.copyOf(e.fileIds()),e==null?null:e.expectedTaskEndAt()); + } + + /** 只允许平台发起整车写命令。 */ + private static void requirePlatform(Actor actor) { if(actor==null || actor.mode()!=ActorMode.PLATFORM || actor.operatorId()==null) throw new BizException(ResultCode.FORBIDDEN,"仅平台可安排整车作业"); } + /** 保存动作时不可变结果。 */ + private static CommandResult result(DispatchAggregate row,CommandIdentity identity,DispatchResults.Payload payload) { + return new CommandResult(row.getDispatchNo(),identity.action(),identity.idempotencyKey(),row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),Instant.now().truncatedTo(ChronoUnit.MILLIS),Effect.APPLIED,counts(row),payload,false,false); + } + /** 追加原时刻主单快照和操作证据。 */ + private void history(DispatchAggregate row,Actor actor,Action action,String reason) { + if(repository.history(IdWorker.getId(),row,action.name(),actor.operatorId(),reason,write(row),now())!=1) throw conflict("排车操作历史未保存"); + } + /** 当前审核记录,不以版本相等代替轮次有效性。 */ + Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); } + /** 未提交的审核不伪造待审核。 */ + private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); } + /** 整车真实统计。 */ + private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); } + /** 空单永远不能核平为装车完成。 */ + private static boolean reconciled(DispatchAggregate row) { return n(row.getPackageCount())>0 && Objects.equals(row.getPackageCount(),row.getPickedPackageCount()); } + /** 目的地业务标签仅来源于真实目的仓或实际地址。 */ + private String destinationLabel(DispatchItem i) { return "PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():trim(i.getDestinationWarehouseCode())==null?i.getFbaWarehouseCode():i.getDestinationWarehouseCode(); } + /** 目的地稳定业务键复用既有地址规范,不信任用户指定路线。 */ + Destination destination(DispatchItem i) { return new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),i.getDestinationType(),i.getDestinationWarehouseId(),i.getDestinationWarehouseCode(),i.getDestinationWarehouseName(),i.getDeliveryAddress(),i.getPostalCode(),i.getContactName(),i.getContactPhone()); } + /** 严格写入领域快照,失败回滚而不吞字段。 */ + private String write(Object value) { try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("排车快照保存失败",error);} } + /** 严格读取本版快照,不提供旧字段降级解析。 */ + private T read(String value,Class type) { try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("排车快照读取失败",error);} } + /** 数据库实际时间统一保存UTC毫秒。 */ + private static LocalDateTime now() { return utc(Instant.now().truncatedTo(ChronoUnit.MILLIS)); } + /** 可空计划不填假时间。 */ + private static LocalDateTime utc(Instant time) { return time==null?null:LocalDateTime.ofInstant(time,ZoneOffset.UTC); } + /** 读取UTC字段不使用操作者本地时区。 */ + private static Instant instant(LocalDateTime time) { return time==null?null:time.toInstant(ZoneOffset.UTC); } + /** 空统计只作零处理,不用于业务编号回退。 */ + private static int n(Integer value) { return value==null?0:value; } + /** 查询空字符串收敛为无筛选。 */ + private static String trim(String value) { return value==null||value.isBlank()?null:value.trim(); } + /** 明确参数错误。 */ + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST,message); } + /** 明确版本/状态/事实竞争失败。 */ + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java new file mode 100644 index 000000000..158c648d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.BatchAllocation; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.util.*; + +/** 预约提供者薄合同适配,客户归属只从已持久化真实明细取得。 */ +@Service +@Transactional(propagation=Propagation.MANDATORY) +public class DispatchAppointmentAllocations { + private final AppointmentWorkDispatchPort provider; + public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;} + + /** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */ + public Map candidates(List rows) { + var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList(); + if(identities.isEmpty())return Map.of(); + var result=provider.candidateBatches(identities); + if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"原预约候选批读范围不完整,请重新核对"); + return result; + } + + /** 当前保留分配与全部请求合并后一次预锁;同批混版本或缺版本不能绕过。 */ + public void lock(List retained,List requested) { + var versions=new TreeMap(); + if(retained!=null)for(var row:retained)if(row.batchKey()!=null && row.batchKey().startsWith("FBA_APPOINTMENT:"))add(versions,row.batchKey(),row.recordVersion()); + if(requested!=null)for(var row:requested) { + if(row==null || row.batchKey()==null || !row.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*"))throw bad("只能选择原权威FBA预约批次,客户仓引用由服务器生成"); + add(versions,row.batchKey(),row.recordVersion()); + } + provider.lockBatches(Collections.unmodifiableMap(versions)); + } + /** 草稿在来源锁之前锁齐所有车辆分组的全部预约,不能在第二组才新增批次锁。 */ + public void lockCreates(List requests) { + var allocations=new ArrayList(); + for(var request:requests)if(request.batchAllocations()!=null)allocations.addAll(request.batchAllocations()); + lock(List.of(),allocations); + } + /** Provider只增量替换touched;不将同车其他客户原分配删掉。 */ + public void bind(long dispatch,List touched,List requested) { + var items=touched.stream().map(i->new AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList(); + var allocations=requested==null?List.of():requested.stream().map(a-> + new AppointmentWorkDispatchPort.Allocation(a.planPalletId(),a.batchKey(),a.recordVersion(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount())).toList(); + provider.bindItems(dispatch,items,allocations); + } + /** 调用方已锁齐全车原批次且核完整实物,删除只作用于本车指定明细。 */ + public void remove(long dispatch,List items){for(var item:items)provider.removeItem(dispatch,item.getId());} + private static void add(Map versions,String key,Long version) { + if(version==null || version<0)throw bad("预约批次recordVersion必填且不能小于零"); + var existing=versions.putIfAbsent(key,version); + if(existing!=null && !existing.equals(version))throw new BizException(ResultCode.CONFLICT,"同一预约批次版本不一致,请重新核对原批次"); + } + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index 4ec0823cd..ff5af7f79 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -4,7 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; import feign.FeignException; import jakarta.annotation.Resource; import java.math.BigDecimal; @@ -27,13 +27,13 @@ public class DispatchBolPrintService { private static final int CONTINUATION_PAGE_ROWS = 26; private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297")); - @Resource private DispatchService dispatches; + @Resource private DispatchBolReadService dispatches; @Resource private PrintPdfClient print; @Resource private DomesticWarehouseDirectoryClient warehouses; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public GeneratedPdf generate(String dispatchNo, String requestId) { - DispatchResponse detail = dispatches.platformDetail(dispatchNo); + DispatchBolData detail = dispatches.read(dispatchNo); if (detail.bolLines() == null || detail.bolLines().isEmpty()) { throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细"); } @@ -54,16 +54,16 @@ public class DispatchBolPrintService { } } - private List documents(DispatchResponse detail) { + private List documents(DispatchBolData detail) { String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); - Map> groups = new LinkedHashMap<>(); - for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line); + Map> groups = new LinkedHashMap<>(); + for (DispatchBolData.Line line : detail.bolLines()) groups.computeIfAbsent(line.groupKey(), ignored -> new ArrayList<>()).add(line); List documents = new ArrayList<>(); - for (Map.Entry> group : groups.entrySet()) { - List lines = group.getValue(); + for (Map.Entry> group : groups.entrySet()) { + List lines = group.getValue(); int pages = pageCount(lines.size()); - int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum(); - String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); + int total = lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum(); + String destination = lines.stream().map(DispatchBolData.Line::destinationLabel).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); int offset = 0; for (int page = 0; page < pages; page++) { int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS; @@ -72,8 +72,8 @@ public class DispatchBolPrintService { .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), - text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", - group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), + text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", + text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), total, page + 1, pages, rows)); offset = end; } @@ -128,9 +128,9 @@ public class DispatchBolPrintService { return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS; } - private String unique(List lines, boolean shipment) { + private String unique(List lines, boolean shipment) { LinkedHashSet values = new LinkedHashSet<>(); - for (DispatchResponse.BolLine line : lines) { + for (DispatchBolData.Line line : lines) { String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo(); if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java new file mode 100644 index 000000000..00c2c2d28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.service; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; +import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; +/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */ +@Service +public class DispatchBolReadService { + private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json; + public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;} + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchBolData read(String number) { + var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);} + var cargo=lines.findWholeDispatch(row.getId()); + if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实"); + var confirmations=new LinkedHashMap>(); + for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo()); + var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue()))); + return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(), + row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java new file mode 100644 index 000000000..29c7db77c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels; +import com.baiyue.lastleg.dispatch.port.DispatchResults; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +/** 严格按已持久化Action恢复结果,不依赖进程内类型缓存或默认多态。 */ +@Component +public final class DispatchCommandCodec { + private final ObjectMapper json; + + /** 保留正式时间/Long配置,并防止中间树将资金或实测小数转为double丢精度。 */ + public DispatchCommandCodec(ObjectMapper json) { + this.json = json.copy().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .setNodeFactory(JsonNodeFactory.withExactBigDecimals(true)); + } + + /** 原动作与结果类型必须匹配后才能写入不可变账本。 */ + public String encode(DispatchContracts.CommandResult result) { + if (result == null || result.action() == null || result.result() == null + || !payloadType(result.action()).isInstance(result.result())) { + throw new IllegalArgumentException("排车动作与命令结果类型不匹配"); + } + try { return json.writeValueAsString(result); } + catch (JsonProcessingException error) { throw new IllegalStateException("排车命令结果保存失败", error); } + } + + /** 从数据库result_json解码,禁止猜测历史类型或回退为空成功结果。 */ + public DispatchContracts.CommandResult decode(String stored) { + try { + JsonNode root = json.readTree(stored); + DispatchTypes.Action action = read(root, "action", DispatchTypes.Action.class); + Long version = read(root, "recordVersion", Long.class); + if (version < 0) throw new IllegalArgumentException("命令结果版本不得为负"); + JsonNode payload = required(root, "result"); + for (String field : mandatoryPayloadFields(action)) required(payload, field); + var result = new DispatchContracts.CommandResult( + read(root, "dispatchNo", String.class), action, + read(root, "idempotencyKey", String.class), version, + read(root, "status", DispatchTypes.Status.class), read(root, "stage", DispatchTypes.Stage.class), + read(root, "occurredAt", Instant.class), read(root, "effect", DispatchTypes.Effect.class), + read(root, "counts", DispatchContracts.Counts.class), + json.treeToValue(payload, payloadType(action)), + read(root, "replayed", Boolean.class), read(root, "alreadyArranged", Boolean.class)); + return result; + } catch (JsonProcessingException | IllegalArgumentException error) { + throw new IllegalStateException("排车命令账本结果损坏,不能恢复为成功", error); + } + } + + /** 原结果仅改变重放标记,原版本、阶段、单据与事实时间均保持。 */ + public DispatchContracts.CommandResult replay(String stored) { + var original = decode(stored); + return new DispatchContracts.CommandResult(original.dispatchNo(), original.action(), original.idempotencyKey(), + original.recordVersion(), original.status(), original.stage(), original.occurredAt(), original.effect(), + original.counts(), original.result(), true, original.alreadyArranged()); + } + + /** 封闭动作集合决定具体类型,不启用任意类名反序列化。 */ + private static Class payloadType(DispatchTypes.Action action) { + return switch (action) { + case PICK -> DispatchResults.Picked.class; + case LOADING_COMPLETE -> DispatchResults.Loaded.class; + case DEPART -> DispatchResults.Departed.class; + case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class; + case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, + DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class; + }; + } + + /** 防止将其他动作JSON缺字段默认为零或null后冒充有效结果。 */ + private static String[] mandatoryPayloadFields(DispatchTypes.Action action) { + return switch (action) { + case PICK -> new String[] { "dispatchItemId", "packages", "pickedPackages", "pickingReconciled" }; + case LOADING_COMPLETE -> new String[] { "loadingCompletionHash", "loadingCompletedAt", "confirmedLoad" }; + case DEPART -> new String[] { "departedAt", "shippedPackages", "outbounds", "deliveries" }; + case ARRANGE_DELIVERY -> new String[] { "dispatchNo", "recordVersion", "arrangementKey", "cargoHash", "arrangedAt", "outbounds", "deliveries" }; + default -> new String[] { "dispatchNo" }; + }; + } + + /** 账本缺少字段即失败,不将缺少布尔或数值解释为默认值。 */ + private static JsonNode required(JsonNode root, String field) { + if (root == null || !root.isObject() || !root.hasNonNull(field)) + throw new IllegalArgumentException("排车命令结果缺少字段:" + field); + return root.get(field); + } + + /** 使用正式ObjectMapper解析对应显式类型。 */ + private T read(JsonNode root, String field, Class type) throws JsonProcessingException { + return json.treeToValue(required(root, field), type); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java new file mode 100644 index 000000000..baa4ab651 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; + +/** 命令身份与派送业务分别取摘要,禁止换主体或换版本冒充原请求。 */ +@Component +public final class DispatchCommandHasher { + private static final Set UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services", + "batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds"); + private final ObjectMapper json; + + /** 复用已注册的时间、数值和公开字段序列化配置。 */ + public DispatchCommandHasher(ObjectMapper json) { this.json = json; } + + /** 严格摘要绑定可信主体、动作、业务号及原版本,渠道不改变命令身份。 */ + public String command(DispatchContracts.Actor actor, DispatchTypes.Action action, String dispatchNo, + Long recordVersion, Object request) { + var root = json.createObjectNode(); + root.put("operatorId", actor.operatorId()); + root.put("action", action.name()); + root.put("dispatchNo", dispatchNo); + if (recordVersion == null) root.putNull("recordVersion"); + else root.put("recordVersion", recordVersion); + JsonNode payload = json.valueToTree(request); + if (payload.isObject()) ((com.fasterxml.jackson.databind.node.ObjectNode) payload).remove("idempotencyKey"); + root.set("payload", payload); + return sha256(canonical(root, "")); + } + + /** 同车安排只按业务证据去重,不受操作者、请求键或纯资格版本影响。 */ + public String arrangement(String dispatchNo, DispatchDeliveryModels.ArrangeDelivery request, + DispatchContracts.Resources resources) { + var root = json.createObjectNode(); + root.put("dispatchNo", dispatchNo); + var payload = (com.fasterxml.jackson.databind.node.ObjectNode) json.valueToTree(request); + payload.remove(List.of("idempotencyKey", "recordVersion")); + root.set("payload", payload); + root.set("resources", json.valueToTree(resources)); + return sha256(canonical(root, "")); + } + + /** 来源版本复用同一规范化算法,调用方必须提供完整权威事实。 */ + public String source(Object authoritativeFacts) { return sha256(canonical(json.valueToTree(authoritativeFacts), "")); } + + /** 无序集合按规范内容排序;路线/站点等有序数组必须保留次序。 */ + private String canonical(JsonNode node, String field) { + if (node == null || node.isNull()) return "null"; + if (node.isObject()) { + var values = new TreeMap(); + node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()))); + var result = new ArrayList(); + values.forEach((key, value) -> result.add(quoted(key) + ":" + value)); + return "{" + String.join(",", result) + "}"; + } + if (node.isArray()) { + var values = new ArrayList(); + node.forEach(value -> values.add(canonical(value, ""))); + if (UNORDERED.contains(field)) values.sort(String::compareTo); + return "[" + String.join(",", values) + "]"; + } + if (node.isNumber()) return node.decimalValue().stripTrailingZeros().toPlainString(); + return node.toString(); + } + + /** 使用Jackson转义字段名,避免自制引号破坏规范JSON。 */ + private String quoted(String value) { return json.getNodeFactory().textNode(value).toString(); } + + /** 标准SHA256输出固定小写十六进制,不使用进程相关hashCode。 */ + private static String sha256(String value) { + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException("运行环境缺少SHA256", error); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java new file mode 100644 index 000000000..b93fc9ef9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.repository.DispatchCommandRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; + +/** 严格命令身份和不可变结果;当前权限/绑定仓必须由领域入口先核验。 */ +@Service +public class DispatchCommandLedger { + private final DispatchCommandRepository repository; + private final DispatchCommandCodec codec; + + /** 数据访问经过Repository,编解码不依赖进程内结果对象。 */ + public DispatchCommandLedger(DispatchCommandRepository repository, DispatchCommandCodec codec) { + this.repository = repository; + this.codec = codec; + } + + /** 原键恢复优先于当前版本判断,但绝不向另一主体披露原结果。 */ + @Transactional(propagation = Propagation.MANDATORY) + public Optional replay(DispatchContracts.Actor actor, String dispatchNo, + DispatchContracts.CommandIdentity identity) { + requireIdentity(actor, identity); + DispatchCommandRecord record = repository.find(identity.idempotencyKey()); + if (record == null) return Optional.empty(); + if (!Objects.equals(record.operatorId(), actor.operatorId())) + throw new BizException(ResultCode.FORBIDDEN, "该请求标识不属于当前操作员"); + boolean creatingUnknownNumber = dispatchNo == null && identity.action() == com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.CREATE; + if ((!creatingUnknownNumber && !Objects.equals(record.dispatchNo(), dispatchNo)) || !Objects.equals(record.action(), identity.action().name()) + || record.recordVersion() != identity.recordVersion() || !Objects.equals(record.commandHash(), identity.commandHash())) + throw new BizException(ResultCode.CONFLICT, "原请求标识不能更换排车、动作、版本或内容"); + var result = codec.replay(record.resultJson()); + if (!Objects.equals(result.dispatchNo(), record.dispatchNo()) || result.action() != identity.action() + || !Objects.equals(result.idempotencyKey(), record.idempotencyKey())) + throw new IllegalStateException("排车命令账本身份与持久结果不一致"); + return Optional.of(result); + } + + /** GET恢复原命令:先检查当前主体/目标/原动作权限,再从持久JSON读取。 */ + @Transactional(propagation = Propagation.MANDATORY) + public DispatchContracts.CommandResult readOriginal(DispatchContracts.Actor actor, String dispatchNo, String key, + java.util.Set allowedActions) { + var record = repository.find(key); + if (record == null) throw new BizException(ResultCode.NOT_FOUND,"未找到该请求的已提交结果,请保持原请求核对"); + if (actor == null || !Objects.equals(record.operatorId(),actor.operatorId()) || !Objects.equals(dispatchNo,record.dispatchNo())) + throw new BizException(ResultCode.FORBIDDEN,"无权读取该请求结果"); + var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.valueOf(record.action()); + if (!allowedActions.contains(action)) throw new BizException(ResultCode.FORBIDDEN,"当前没有原动作权限"); + return replay(actor,dispatchNo,new DispatchContracts.CommandIdentity(action,key,record.recordVersion(),record.commandHash())).orElseThrow(); + } + + /** 调用方已完成状态CAS后在同一事务保存结果,任何唯一键冲突必须整体回滚。 */ + @Transactional(propagation = Propagation.MANDATORY) + public void append(Long dispatchId, DispatchContracts.Actor actor, DispatchContracts.CommandIdentity identity, + DispatchContracts.CommandResult result) { + requireIdentity(actor, identity); + if (result.replayed() || result.action() != identity.action() + || !Objects.equals(result.idempotencyKey(), identity.idempotencyKey())) + throw new IllegalArgumentException("只能保存本次命令的原始结果"); + var row = new DispatchCommandRecord(IdWorker.getId(), dispatchId, result.dispatchNo(), identity.action().name(), + actor.operatorId(), identity.idempotencyKey(), identity.recordVersion(), identity.commandHash(), codec.encode(result), + LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS), ZoneOffset.UTC)); + try { + if (repository.insert(row) != 1) throw new IllegalStateException("排车命令结果未保存"); + } catch (DuplicateKeyException error) { + // 不在已经写过主单/费用的事务中吞掉冲突返回成功,外层必须回滚后原键恢复。 + throw new BizException(ResultCode.CONFLICT, "请求标识已使用,请保持原内容核对原结果"); + } + } + + /** 只接受可信主体和服务端产生的完整摘要,不允许空键或伪摘要。 */ + private static void requireIdentity(DispatchContracts.Actor actor, DispatchContracts.CommandIdentity identity) { + if (actor == null || actor.operatorId() == null || actor.operatorId().isBlank()) + throw new BizException(ResultCode.FORBIDDEN, "缺少可信操作身份"); + if (identity == null || identity.action() == null || identity.idempotencyKey() == null + || identity.idempotencyKey().isBlank() || identity.idempotencyKey().length() > 255 + || identity.recordVersion() < 0 || identity.commandHash() == null || !identity.commandHash().matches("[0-9a-f]{64}")) + throw new BizException(ResultCode.BAD_REQUEST, "命令标识、版本或摘要无效"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java new file mode 100644 index 000000000..918b346ec --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository; +import com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper.RescanCandidate; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 提交后的正常签收汇总;父CAS、唯一完成历史和指定信号确认必须共同提交。 */ +@Service +public class DispatchCompletionService implements DispatchCompletionPort { + private static final Actor SYSTEM=new Actor("SYSTEM_DELIVERY_RECONCILE",ActorMode.PLATFORM,null); + private final DispatchAggregateRepository repository; + private final DispatchExecutionRepository reads; + private final FleetTripExecutionService fleet; + private final DispatchExecutionPort execution; + private final DispatchDeliveryPort delivery; + private final DispatchSignalPort signals; + private final ObjectMapper json; + /** 仅依赖共同端口,禁止持签收子锁回调父流程。 */ + public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet, + DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json) { + this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json; + } + /** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */ + @Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) + public CompletionResult reconcile(Signal signal) { + if(signal==null || signal.signalId()==null || signal.signalId()<=0 || signal.dispatchNo()==null || signal.deliveryNo()==null)throw new IllegalArgumentException("签收信号身份不完整"); + var identity=fleet.lockDispatchCompletion(signal.dispatchNo());var row=repository.lock(signal.dispatchNo()); + if(row==null || !Objects.equals(row.getTripId(),identity.tripId()))throw new IllegalStateException("整车身份已变化"); + var context=execution.preview(SYSTEM,signal.dispatchNo());var facts=delivery.normalDeliveryFacts(context); + if(facts==null || !Objects.equals(facts.dispatchNo(),row.getDispatchNo()) || facts.expectedDeliveryNos()==null || !facts.expectedDeliveryNos().contains(signal.deliveryNo()))throw new IllegalStateException("签收信号不属于本车已安排派送"); + if(!Status.ACTIVE.name().equals(row.getStatus()) || !Set.of(Stage.DELIVERING.name(),Stage.DELIVERED.name()).contains(row.getStage()))throw new IllegalStateException("本车尚无合法在途运输事实"); + boolean changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC); + if(Stage.DELIVERING.name().equals(row.getStage()) && facts.allNormallySigned()) { + if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据"); + long version=row.getRecordVersion();row.setStage(Stage.DELIVERED.name());row.setDeliveryCompletedAt(LocalDateTime.ofInstant(facts.completedAt(),ZoneOffset.UTC)); + row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(now); + if(repository.saveState(row,version)!=1)throw new IllegalStateException("整车完成版本竞争失败"); + if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存"); + changed=true; + } + // 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。 + signals.acknowledge(signal,Instant.now()); + return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(), + row.getDeliveryCompletedAt()==null?null:row.getDeliveryCompletedAt().toInstant(ZoneOffset.UTC)); + } + /** 普通读候选不持子锁跨入汇总或重建事务。 */ + @Transactional(readOnly=true) + public List rescanCandidates(int limit) { + if(limit<1 || limit>200)throw new IllegalArgumentException("重扫范围不合法");return reads.rescan(limit); + } + /** 每个缺失信号独立短事务重核真实派送版本,竞争失败不阻塞下一张车。 */ + @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) + public void rescan(RescanCandidate candidate) {signals.enqueue(candidate.dispatchNo(),candidate.deliveryNo(),candidate.deliveryVersion(),Instant.now());} + /** 证据序列化失败必须随主阶段一起回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("正常签收证据保存失败",error);}} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java new file mode 100644 index 000000000..5e39f67a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchSignalPort; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.time.Instant; + +/** 有界、可重复的持久信号消费者,不在子事务持锁期间调用汇总。 */ +@Component +public class DispatchDeliveryCompletionWorker { + private static final Logger LOG=LoggerFactory.getLogger(DispatchDeliveryCompletionWorker.class); + private final DispatchCompletionService completion; + private final DispatchSignalPort signals; + /** 调用独立代理服务,确保失败事务已回滚再持久记录退避。 */ + public DispatchDeliveryCompletionWorker(DispatchCompletionService completion,DispatchSignalPort signals){this.completion=completion;this.signals=signals;} + /** 每批最多100条;单车失败不终止其他车次,已确认信号不会被失败记录重新激活。 */ + @Scheduled(fixedDelayString="${lastleg.dispatch.delivery-completion-poll-ms:5000}") + public void consume() { + for(var signal:signals.due(Instant.now(),100))try{completion.reconcile(signal);}catch(RuntimeException error){ + long delay=Math.min(900L,5L<();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId()); + for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车"); + var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey()); + if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平"); + sourceWorkflow.departWholeSources(context,actor,departed,at); + workflow.releaseSources(row,actor,repository.cargo(row.getId()),LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion(); + row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(departed.shippedPackages()));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败"); + if(repository.history(IdWorker.getId(),row,Action.DEPART.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"departure",departed)),row.getUpdatedAt())!=1)throw conflict("发车历史未保存"); + var result=new CommandResult(number,Action.DEPART,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.DELIVERING,at,Effect.APPLIED, + new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Departed(at,departed.shippedPackages(),departed.outbounds(),departed.deliveries()),false,false); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 原装车快照不能缺项回退成当前建议值。 */ + private T read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("装车快照读取失败",error);}} + /** 发车证据失败与单据更新一起回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("发车证据保存失败",error);}} + /** 并发、来源或阶段不满足时整条命令失败。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java new file mode 100644 index 000000000..a6c4667e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java @@ -0,0 +1,479 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchDraft; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftCommand; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; +import com.baiyue.lastleg.dispatch.mapper.DispatchDraftClaimMapper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchDraftRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.util.*; +import java.util.function.Consumer; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; + +/** 平台单仓多客户草稿;持久命令、服务器来源、排他占用和整批创建共同提交。 */ +@Service +public class DispatchDraftService implements DispatchPlanningPort { + private static final TypeReference> CANDIDATES = new TypeReference<>() { }; + private static final TypeReference> KEYS = new TypeReference<>() { }; + private final DispatchDraftRepository repository; + private final DispatchSourceRepository sources; + private final DispatchService sourceRules; + private final DispatchAggregateService aggregate; + private final DispatchCommandHasher hashes; + private final DispatchCommandCodec commandCodec; + private final ObjectMapper json; + private final Clock clock; + private final AppointmentWorkDispatchPort appointmentProvider; + private final DispatchPhysicalClaimService physicalClaims; + private final PhysicalPalletSerialPort palletSerial; + + /** 只装配草稿仓储和实际来源/主单提供者,不持久化客户端伪造候选。 */ + @org.springframework.beans.factory.annotation.Autowired + public DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules, + DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,DispatchPhysicalClaimService physicalClaims,PhysicalPalletSerialPort palletSerial) { + this(repository,sources,sourceRules,aggregate,hashes,json,commandCodec,appointmentProvider,physicalClaims,palletSerial,Clock.systemUTC()); + } + + /** 测试可控制锁等待跨越的时间边界,生产始终使用UTC系统时钟。 */ + DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules, + DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,DispatchPhysicalClaimService physicalClaims,PhysicalPalletSerialPort palletSerial,Clock clock) { + this.repository=repository;this.sources=sources;this.sourceRules=sourceRules; + this.aggregate=aggregate;this.hashes=hashes;this.json=json;this.commandCodec=commandCodec;this.appointmentProvider=appointmentProvider;this.physicalClaims=physicalClaims;this.palletSerial=palletSerial;this.clock=Objects.requireNonNull(clock); + } + + /** 候选继续委托主流程已有真实查询和权限范围。 */ + @Override public CandidateList candidates(Actor actor,CandidateQuery query){platform(actor);return aggregate.candidates(actor,query);} + + /** 只读已保存来源快照,不把历史版本推断成最新可转换资格。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public List drafts(Actor actor){platform(actor);var rows=repository.active(actor.operatorId());var batches=readCandidateBatches(rows.stream().flatMap(r->selected(r).stream()).toList());return rows.stream().map(r->view(r,batches)).toList();} + + /** 服务器生成token,零版本创建结果可在进程重启后按原命令恢复。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft createDraft(Actor actor,CreateDraft request) { + platform(actor);if(request==null || request.warehouseId()==null || !request.warehouseId().matches("[1-9][0-9]*"))throw bad("请选择海外起运仓"); + var command=command(actor,"CREATE_DRAFT","",request.idempotencyKey(),request); + if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class); + var now=now();var row=new DispatchDraft();row.setId(IdWorker.getId()); + row.setDraftToken("DD-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT)); + row.setOperatorId(actor.operatorId());row.setWarehouseId(request.warehouseId());row.setRecordVersion(0L); + row.setPlanIdsJson("[]");row.setContainersJson("[]");row.setSourcesJson("[]");row.setStatus("ACTIVE"); + row.setCreatedAt(now);row.setUpdatedAt(now);row.setExpiresAt(now.plusMinutes(30));repository.insert(row); + return complete(command,view(row)); + } + + /** 新容器只归本拥有者草稿,容量沿既有15/30托选项。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft createContainer(Actor actor,CreateContainer request) { + Objects.requireNonNull(request);capacity(request.capacity()); + return change(actor,"CREATE_CONTAINER",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var all=repository.containers(row.getDraftToken());if(all.size()>=100)throw bad("每个草稿最多100个容器"); + var container=new DispatchDraftContainer();container.setId(IdWorker.getId());container.setDraftId(row.getId());container.setDraftToken(row.getDraftToken()); + container.setSequenceNo(all.stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0)+1); + container.setCapacity(request.capacity());container.setSourceKeysJson("[]");container.setCreatedAt(now());container.setUpdatedAt(now());repository.insert(container); + }); + } + + /** 单来源请求由服务端扩展完整组,一次命令锁定并加入全部成员及共享物理占用。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft select(Actor actor,DraftSelection request) { + Objects.requireNonNull(request); + return change(actor,"SELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source()); + var group=sourceRules.selectionGroup(row.getWarehouseId(),request.source()); + var members=groupMembers(group);var requested=members.get(key); + if(requested==null || !Objects.equals(requested.selection(),request.source()))throw conflict("来源身份、版本或完整组成已变化"); + if(selected.stream().anyMatch(c->members.containsKey(c.sourceKey())))throw conflict("完整来源组已全部或部分在本草稿,请重新核对组成"); + var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId()); + var selections=new ArrayList<>(selected.stream().map(Candidate::selection).toList());members.values().stream().map(SelectionGroupSource::selection).forEach(selections::add); + var locked=sourceRules.lockDraftSources(row.getWarehouseId(),selections); + var lockedIndex=new TreeMap();for(var source:locked)if(lockedIndex.putIfAbsent(source.sourceKey(),source)!=null)throw conflict("锁内来源组成重复"); + if(!lockedIndex.keySet().equals(selectionIndex(selections).keySet()))throw conflict("锁内来源组成员已变化"); + requireLease(row.getExpiresAt()); + verifyClaims(row,selected,containers); + var next=new ArrayList<>(selected);var keys=new ArrayList<>(keys(container)); + for(var member:members.values()) { + var resolved=lockedIndex.get(member.sourceKey()); + if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId()) + || resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化"); + next.add(candidate(resolved,group));keys.add(member.sourceKey()); + } + requireCapacity(container.getCapacity(),keys,index(next)); + var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream() + .map(member->member.selection().warehousePalletId()).filter(Objects::nonNull).distinct().toList(),at); + requireLease(row.getExpiresAt()); + for(var member:members.values()) { + var resolved=lockedIndex.get(member.sourceKey());sources.expire(member.customerId(),member.sourceKey(),at); + if(!sources.claims(member.customerId(),member.sourceKey(),at).isEmpty())throw conflict("所选来源已被其他作业占用"); + try {repository.claim(IdWorker.getId(),row,container.getId(),new DispatchDraftRepository.DispatchServiceSource(member.customerId(),member.sourceKey(), + resolved.candidate().planPalletId(),resolved.candidate().warehousePalletId(),resolved.candidate().returnReceiptId(),physical.get(resolved.candidate().warehousePalletId())),at);} + catch(DuplicateKeyException error){throw conflict("完整组中的来源已被其他作业占用");} + } + container.setSourceKeysJson(write(keys));container.setUpdatedAt(at);repository.layout(container);row.setSourcesJson(write(next)); + }); + } + + /** 按原冻结完整组原子解除;真实来源变旧不阻止拥有者释放原占用。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft unselect(Actor actor,DraftSelection request) { + Objects.requireNonNull(request); + return change(actor,"UNSELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source()); + var current=index(selected).get(key);if(current==null || !Objects.equals(current.selection(),request.source()))throw conflict("原来源身份或版本不一致"); + var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId());var keys=new ArrayList<>(keys(container)); + var members=groupMembers(current.selectionGroup()).keySet(); + var claims=verifyClaims(row,selected,containers); + if(!keys.containsAll(members))throw conflict("完整来源组不属于本容器"); + var originalPhysical=claims.stream().filter(c->members.contains(c.sourceKey())).map(DispatchDraftClaimMapper.Claim::physicalLockId).filter(Objects::nonNull).distinct().toList(); + for(var member:members)if(repository.release(row,member,container.getId(),now())!=1)throw conflict("原完整来源组占用已变化"); + physicalClaims.releaseIfUnused(physicalOwner(row,container.getId()),originalPhysical,now()); + keys.removeAll(members); + container.setSourceKeysJson(write(keys));container.setUpdatedAt(now());repository.layout(container); + row.setSourcesJson(write(selected.stream().filter(c->!members.contains(c.sourceKey())).toList())); + }); + } + + /** 完整布局只能重排当前服务器来源和容器,不能用JSON新增货物或绕过容量。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft saveSnapshot(Actor actor,DraftSnapshot request) { + Objects.requireNonNull(request); + return change(actor,"SNAPSHOT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);var old=repository.containers(row.getDraftToken()); + if(request.containers()==null || request.containers().size()!=old.size())throw bad("布局必须包含全部原容器"); + var remaining=new HashSet<>(old.stream().map(DispatchDraftContainer::getId).toList());var seen=new HashSet();var sequence=new HashSet(); + var mapping=new HashMap();var byKey=index(selected); + for(var c:request.containers()) { + if(c==null || !remaining.remove(c.draftContainerId()) || c.sequenceNo()<1 || !sequence.add(c.sequenceNo()) || c.sourceKeys()==null)throw bad("容器身份或顺序不完整"); + capacity(c.capacity());requireCapacity(c.capacity(),c.sourceKeys(),byKey); + for(var key:c.sourceKeys()){if(!seen.add(key))throw bad("同一来源不能重复放入容器");mapping.put(key,c.draftContainerId());} + } + if(!remaining.isEmpty() || !seen.equals(byKey.keySet()))throw conflict("布局只能完整移动当前已锁来源"); + requireWholeLayout(selected,mapping); + if(request.planIds()==null || request.planIds().stream().anyMatch(id->id==null || id<=0) || request.planIds().size()>500)throw bad("计划筛选不合法"); + sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList()); + var claims=verifyClaims(row,selected,old);var at=now(); + for(var claim:claims)if(!Objects.equals(claim.containerId(),mapping.get(claim.sourceKey())))repository.move(row,claim,mapping.get(claim.sourceKey()),at); + var physicalMoves=new TreeMap>>(); + for(var claim:claims)if(claim.physicalLockId()!=null && !Objects.equals(claim.containerId(),mapping.get(claim.sourceKey()))) + physicalMoves.computeIfAbsent(claim.containerId(),ignored->new TreeMap<>()) + .computeIfAbsent(mapping.get(claim.sourceKey()),ignored->new TreeSet<>()).add(claim.physicalLockId()); + // 全部逻辑成员先完成布局,再由物理提供者按实际成员目标迁移整托,不能把原容器其他组带走。 + for(var previous:physicalMoves.entrySet())for(var next:previous.getValue().entrySet()) + physicalClaims.moveDraft(physicalOwner(row,previous.getKey()),List.copyOf(next.getValue()),next.getKey(),at); + repository.stageSequences(row.getDraftToken()); + for(var c:request.containers()) {var existing=container(old,c.draftContainerId());existing.setSequenceNo(c.sequenceNo());existing.setCapacity(c.capacity()); + existing.setSourceKeysJson(write(c.sourceKeys()));existing.setUpdatedAt(at);repository.layout(existing);} + row.setPlanIdsJson(write(request.planIds())); + }); + } + + /** 仅移除空容器;来源必须通过显式解除动作处理。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft removeContainer(Actor actor,String token,Long id,DispatchCommands.ReasonCommand request) { + Objects.requireNonNull(request);if(request.reason()==null || request.reason().isBlank())throw bad("移除容器原因必填"); + return change(actor,"REMOVE_CONTAINER",token,request.recordVersion(),request.idempotencyKey(),Arrays.asList(token,id,request),row->{ + var container=container(repository.containers(token),id); + if(!keys(container).isEmpty() || repository.claims(row,now()).stream().anyMatch(c->Objects.equals(c.containerId(),id)))throw conflict("仅空容器可以删除"); + repository.remove(token,container.getId()); + }); + } + + /** 释放原拥有者草稿,绝不按过期token释放后来归属正式单或其他人的记录。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft release(Actor actor,DraftRelease request) { + Objects.requireNonNull(request); + return change(actor,"RELEASE",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var containers=repository.containers(row.getDraftToken());var claims=verifyClaims(row,selected(row),containers); + if(repository.release(row,null,null,now())!=claims.size())throw conflict("草稿来源未完整释放"); + releasePhysical(row,containers,claims); + row.setStatus("RELEASED");row.setReleasedAt(now());row.setSourcesJson("[]"); + for(var c:repository.containers(row.getDraftToken())){c.setSourceKeysJson("[]");c.setUpdatedAt(now());repository.layout(c);} + }); + } + + /** 全组先资源后来源,随后释放原草稿占用并调用真实整车创建;任一失败全批回滚。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public ConvertedDraft convert(Actor actor,ConvertDraft request) { + Objects.requireNonNull(request);platform(actor); + var command=command(actor,"CONVERT",request.draftToken(),request.idempotencyKey(),request); + if(command.getResultJson()!=null)return readConverted(command.getResultJson(),request.draftToken()); + var row=owned(actor,request.draftToken(),request.recordVersion());var deadline=row.getExpiresAt();var selected=selected(row);var containers=repository.containers(row.getDraftToken()); + var nonempty=containers.stream().filter(c->!keys(c).isEmpty()).toList(); + if(request.groups()==null || request.groups().isEmpty() || request.groups().size()!=nonempty.size())throw bad("须一次转换全部非空容器"); + var byId=new TreeMap();var childKeys=new HashSet(); + for(var group:request.groups()) { + if(group==null || group.dispatch()==null || group.draftContainerId()==null || byId.putIfAbsent(group.draftContainerId(),group)!=null)throw bad("转换分组缺失或重复"); + var c=container(nonempty,group.draftContainerId());var create=group.dispatch(); + if(!Objects.equals(create.warehouseId(),row.getWarehouseId()))throw conflict("整批转换必须使用草稿原起运仓"); + if(create.idempotencyKey()==null || create.idempotencyKey().isBlank() || !childKeys.add(create.idempotencyKey()))throw bad("每辆车须有独立创建命令键"); + var original=selected.stream().filter(s->keys(c).contains(s.sourceKey())).map(Candidate::selection).toList(); + if(!selectionIndex(original).equals(selectionIndex(create.cargo())))throw conflict("创建货物必须精确等于原容器来源及版本"); + requireCapacity(c.getCapacity(),keys(c),index(selected)); + } + if(!byId.keySet().equals(new TreeSet<>(nonempty.stream().map(DispatchDraftContainer::getId).toList())))throw bad("转换必须覆盖全部原容器"); + var placement=new HashMap();for(var c:containers)for(var key:keys(c))placement.put(key,c.getId());requireWholeLayout(selected,placement); + // 该提供者只锁全批资源,不写主单;绝不能先锁来源再锁下一辆车的资源。 + aggregate.lockDraftCreationResources(actor,byId.values().stream().map(DraftVehicle::dispatch).toList()); + sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList()); + requireLease(deadline); + var claims=verifyClaims(row,selected,containers); + if(repository.release(row,null,null,now())!=selected.size())throw conflict("草稿来源占用未完整核齐"); + releasePhysical(row,containers,claims); + var results=new ArrayList(); + for(var group:byId.values()) {var result=aggregate.create(actor,group.dispatch()); + if(result==null || result.replayed() || result.action()!=DispatchTypes.Action.CREATE)throw conflict("子创建命令已被其他批次使用"); + results.add(new ConvertedVehicle(group.draftContainerId(),result));} + long version=row.getRecordVersion();row.setRecordVersion(Math.addExact(version,1));row.setStatus("CONVERTED");row.setReleasedAt(now());row.setUpdatedAt(now());repository.save(row,version); + var result=complete(command,new ConvertedDraft(row.getDraftToken(),List.copyOf(results),false)); + requireLease(deadline);return result; + } + + /** 草稿写操作统一先恢复原命令,再校验当前拥有者、生命周期与公共版本。 */ + private Draft change(Actor actor,String action,String token,long version,String key,Object request,Consumer mutation) { + platform(actor);var command=command(actor,action,token,key,request);if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class); + var row=owned(actor,token,version);var deadline=row.getExpiresAt(); + if(Set.of("UNSELECT","RELEASE","CREATE_CONTAINER","REMOVE_CONTAINER").contains(action)) { + // 纯释放、租约操作使用原冻结身份先取共同串行点,再读逻辑/物理占用;不刷新来源资格。 + // SELECT/SNAPSHOT 仍由来源提供者按申请、订单→Serial取锁,不能在此提前取得其guard。 + palletSerial.lock(selected(row).stream().map(c->c.selection().warehousePalletId()).filter(Objects::nonNull).distinct().sorted().toList()); + requireLease(deadline); + } + mutation.accept(row);long before=row.getRecordVersion(); + requireLease(deadline); + var at=now();row.setRecordVersion(Math.addExact(before,1));row.setUpdatedAt(at); + if("ACTIVE".equals(row.getStatus())){ + var previousExpiresAt=row.getExpiresAt();row.setExpiresAt(at.plusMinutes(30)); + // 先核对并延长原物理租约,再同步逻辑来源与根;同刻操作允许原期限不变。 + physicalClaims.renewDraft(row.getWarehouseId(),row.getDraftToken(),row.getOperatorId(),at,previousExpiresAt,row.getExpiresAt()); + int renewed=repository.renew(row,at); + requireLease(deadline); + var claims=verifyClaims(row,selected(row),repository.containers(token)); + // 部分驱动对未改变值返回0,最终以锁内有效行、精确归属和完整到期时间证明续约;不能只延长根。 + if(renewed<0 || renewed>claims.size())throw conflict("草稿来源续约集合不完整"); + } + repository.save(row,before);var result=complete(command,view(row));requireLease(deadline);return result; + } + + /** 同键完整身份不一致拒绝,结果未知时只读取数据库原结果,不推断未执行。 */ + private DispatchDraftCommand command(Actor actor,String action,String token,String key,Object payload) { + if(key==null || key.isBlank() || key.length()>128)throw bad("命令键必填且最多128字符"); + if(token==null || token.length()>128)throw bad("草稿标识无效"); + var command=new DispatchDraftCommand();command.setId(IdWorker.getId());command.setIdempotencyKey(key);command.setOperatorId(actor.operatorId()); + command.setAction(action);command.setTargetToken(token);command.setRequestHash(hashes.source(Arrays.asList(actor.operatorId(),actor.mode(),action,token,payload)));command.setCreatedAt(now()); + var saved=repository.reserve(command); + if(saved==null || !Objects.equals(saved.getOperatorId(),actor.operatorId()) || !Objects.equals(saved.getAction(),action) + || !Objects.equals(saved.getTargetToken(),token) || !Objects.equals(saved.getRequestHash(),command.getRequestHash()))throw conflict("相同命令键的主体、动作、版本或载荷不同"); + if(!Objects.equals(saved.getId(),command.getId()) && saved.getResultJson()==null)throw new IllegalStateException("已提交草稿命令缺少原结果"); + return saved; + } + + /** 精确拥有者查询避免暴露他人草稿;正常变更不复活过期或已转换状态。 */ + private DispatchDraft owned(Actor actor,String token,long version) { + var row=repository.lock(token,actor.operatorId());if(row==null)throw new BizException(ResultCode.NOT_FOUND,"草稿不存在或不属于当前操作者"); + if(!"ACTIVE".equals(row.getStatus()))throw conflict("草稿已释放或转换");requireLease(row.getExpiresAt()); + if(row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("草稿版本已变化,请刷新");return row; + } + + /** 来源、容器与实际草稿占用逐项相等,不以JSON或总数相等替代归属。 */ + private List verifyClaims(DispatchDraft row,List selected,List containers) { + var claims=repository.claims(row,now());var mapping=new HashMap(); + for(var c:containers)for(var key:keys(c))if(mapping.putIfAbsent(key,c.getId())!=null)throw conflict("草稿来源被重复放入容器"); + var expected=index(selected);if(!mapping.keySet().equals(expected.keySet()) || claims.size()!=expected.size())throw conflict("草稿来源及占用未完整对应"); + requireWholeLayout(selected,mapping); + var physical=new HashMap(); + for(var container:containers)for(var lock:physicalClaims.lockOwner(physicalOwner(row,container.getId()))) { + if(!Objects.equals(lock.getExpiresAt(),row.getExpiresAt()) || physical.putIfAbsent(lock.getWarehousePalletId(),lock)!=null) + throw conflict("草稿物理占用重复或有效期已变化"); + } + var seen=new HashSet();for(var claim:claims){var c=expected.get(claim.sourceKey()); + if(c==null || !seen.add(claim.sourceKey()) || !Objects.equals(c.customerId(),claim.customerId()) + || !Objects.equals(c.selection().warehousePalletId(),claim.palletId()) || !Objects.equals(mapping.get(claim.sourceKey()),claim.containerId()) + || !Objects.equals(row.getExpiresAt(),claim.expiresAt()))throw conflict("草稿原占用身份或有效期已变化"); + var lock=physical.get(claim.palletId()); + if(claim.palletId()==null?claim.physicalLockId()!=null:lock==null || !Objects.equals(lock.getId(),claim.physicalLockId()) + || !Objects.equals(lock.getDraftContainerId(),claim.containerId()))throw conflict("草稿逻辑来源未关联原容器的真实物理占用"); + } + var expectedPallets=selected.stream().map(c->c.selection().warehousePalletId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet()); + if(!physical.keySet().equals(expectedPallets))throw conflict("草稿物理占用与完整来源集合不一致"); + return claims; + } + + /** 草稿真实物理拥有者同时由仓、token、容器及操作者限定。 */ + private DispatchPhysicalClaimService.Owner physicalOwner(DispatchDraft row,long container) { + return new DispatchPhysicalClaimService.Owner(row.getWarehouseId(),null,row.getDraftToken(),container,row.getOperatorId(),row.getExpiresAt()); + } + + /** 逻辑来源全部结束后再按原物理主键释放,不按实托号触碰后续新占用。 */ + private void releasePhysical(DispatchDraft row,List containers,List claims) { + for(var container:containers) { + var ids=claims.stream().filter(c->Objects.equals(c.containerId(),container.getId())).map(DispatchDraftClaimMapper.Claim::physicalLockId) + .filter(Objects::nonNull).distinct().toList(); + if(physicalClaims.releaseIfUnused(physicalOwner(row,container.getId()),ids,now())!=ids.size())throw conflict("草稿实托还有未结束的逻辑来源"); + } + } + + /** 完整组的身份、版本、逻辑成员和实托贡献均由服务端事实明确给出。 */ + private static Map groupMembers(SelectionGroup group) { + if(group==null || !group.complete() || group.groupKey()==null || group.groupKey().isBlank() + || group.groupVersion()==null || group.groupVersion().isBlank() || group.sources()==null || group.sources().isEmpty() + || group.pallets()==null)throw conflict("完整来源组尚未核齐,请重新读取候选"); + var members=new TreeMap(); + for(var member:group.sources()) { + if(member==null || member.selection()==null || member.customerId()==null || member.customerId().isBlank() + || member.selection().sourceVersion()==null || member.selection().sourceVersion().isBlank() || member.packages()<=0 + || !Objects.equals(member.sourceKey(),DispatchService.wholeSourceKey(member.selection())) + || members.putIfAbsent(member.sourceKey(),member)!=null)throw conflict("完整来源组成员身份或版本不完整"); + } + var pallets=new HashSet();var physicalKeys=new HashSet(); + for(var pallet:group.pallets()) { + if(pallet==null || pallet.warehousePalletId()!=null && (pallet.warehousePalletId()<=0 || !pallets.add(pallet.warehousePalletId())) + || pallet.sourceKeys()==null || pallet.sourceKeys().isEmpty())throw conflict("完整来源组实托身份不完整"); + if(pallet.warehousePalletId()==null && pallet.sourceKeys().size()!=1)throw conflict("未关联实托的计划必须保留独立真实来源"); + long packages=0; + for(var key:pallet.sourceKeys()) { + var member=members.get(key); + if(member==null || !physicalKeys.add(key) || !Objects.equals(member.selection().warehousePalletId(),pallet.warehousePalletId())) + throw conflict("完整来源组实托与逻辑成员不一致"); + if(pallet.warehousePalletId()==null && (member.selection().sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN + || member.selection().planPalletId()==null || member.selection().planPalletId()<=0))throw conflict("未关联实托的来源缺少真实计划身份"); + packages=Math.addExact(packages,member.packages()); + } + if(packages!=pallet.packageCount())throw conflict("完整来源组实托箱数未核平"); + } + if(!members.keySet().equals(physicalKeys))throw conflict("完整来源组缺少实际实托或计划成员"); + return members; + } + + /** 任一完整组及同一真实实托只能位于同一容器,不能以布局拆成半托或半申请。 */ + private static void requireWholeLayout(List selected,Map placement) { + var byKey=index(selected);var palletContainers=new HashMap(); + for(var candidate:selected) { + Long container=placement.get(candidate.sourceKey());if(container==null)throw conflict("完整来源缺少容器"); + var members=groupMembers(candidate.selectionGroup()); + if(!members.containsKey(candidate.sourceKey()))throw conflict("草稿来源不在原冻结完整组内"); + for(var member:members.values()) { + var actual=byKey.get(member.sourceKey()); + if(actual==null || !Objects.equals(actual.selection(),member.selection()) || !Objects.equals(actual.customerId(),member.customerId()) + || actual.availablePackages()!=member.packages() || !Objects.equals(actual.sourceVersion(),member.selection().sourceVersion()) + || !Objects.equals(container,placement.get(member.sourceKey())))throw conflict("完整来源组不能拆分到不同容器或改变成员版本"); + } + Long pallet=candidate.selection().warehousePalletId(); + if(pallet!=null) { + var previous=palletContainers.putIfAbsent(pallet,container); + if(previous!=null && !previous.equals(container))throw conflict("同一真实实托不能拆分到不同容器"); + } + } + } + + /** 公开候选快照沿主流程同一字段映射,所有身份和版本来自锁内真实来源。 */ + private Candidate candidate(DispatchService.WholeSource source,SelectionGroup group) { + var r=source.candidate();var d=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode()), + r.destinationType(),r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.deliveryAddress(),r.postalCode(),r.contactName(),r.contactPhone()); + var selection=new DispatchCommands.CargoSelection(source.selection().sourceType(),source.selection().planPalletId(),source.selection().returnReceiptId(),source.selection().relabelNo(),r.warehousePalletId(),source.sourceVersion()); + var candidate=new Candidate(selection,source.sourceKey(),source.sourceVersion(),r.customerId(),r.customerName(),r.overseasWarehouseId(),r.overseasWarehouseCode(),r.overseasWarehouseName(), + r.orderNo(),r.inboundCode(),r.planNo(),r.palletNo(),r.containerNo(),d,r.packageCount(),r.grossWeightKg(),r.volumeCbm(),"SOURCE_SUGGESTION",List.of(),List.of(),group); + return projectCandidate(candidate,readCandidateBatches(List.of(candidate))); + } + + /** 单容器容量按实际托盘去重;未收货计划托盘按真实来源键计位。 */ + private static void requireCapacity(int capacity,List keys,Map sources) { + var physical=new HashSet();var seen=new HashSet(); + for(var key:keys){var source=sources.get(key);if(source==null || !seen.add(key))throw bad("布局包含未知或重复来源"); + physical.add(source.selection().warehousePalletId()==null?key:"PALLET:"+source.selection().warehousePalletId());} + if(physical.size()>capacity)throw conflict("本容器实托数量超过容量"); + } + /** 只接受既有15/30托容器,不将计划容量当成装载实测。 */ + private static void capacity(int capacity){if(capacity!=15 && capacity!=30)throw bad("容器容量仅支持15托或30托");} + /** 按来源键核对完整原始选择,缺失或重复不能退化为部分转换。 */ + private static Map selectionIndex(List selections){ + if(selections==null)throw bad("创建货物来源必填");var result=new TreeMap(); + for(var s:selections)if(result.putIfAbsent(DispatchService.wholeSourceKey(s),s)!=null)throw bad("来源重复");return result; + } + /** 草稿内来源键唯一,不能以覆盖Map掩盖重复持久数据。 */ + private static Map index(List selected){var result=new TreeMap();for(var c:selected)if(result.putIfAbsent(c.sourceKey(),c)!=null)throw conflict("草稿来源重复");return result;} + /** 容器必须是本草稿中实际已创建的服务器身份。 */ + private static DispatchDraftContainer container(List containers,Long id){if(id==null)throw bad("容器标识必填");return containers.stream().filter(c->Objects.equals(c.getId(),id)).findFirst().orElseThrow(()->conflict("容器不属于本草稿"));} + /** 只读服务器持久来源,不解析客户端自定义候选。 */ + private List selected(DispatchDraft row){return read(row.getSourcesJson(),CANDIDATES);} + /** 容器布局只保存来源键,实际资格取草稿服务器来源。 */ + private List keys(DispatchDraftContainer row){return read(row.getSourceKeysJson(),KEYS);} + /** 返回公开单一版本及来源快照,不泄露内部命令或存储字段。 */ + private Draft view(DispatchDraft row){return view(row,readCandidateBatches(selected(row)));} + /** 整页复用一份权威批读;不改来源摘要、草稿版本、占用或命令结果的重放语义。 */ + private Draft view(DispatchDraft row,Map batches){return new Draft(row.getDraftToken(),row.getRecordVersion(),row.getStatus(),row.getWarehouseId(),repository.containers(row.getDraftToken()).stream() + .map(c->new DraftContainer(c.getId(),c.getSequenceNo(),c.getCapacity(),keys(c))).toList(),selected(row).stream().map(c->projectCandidate(c,batches)).toList(),row.getExpiresAt().toInstant(ZoneOffset.UTC));} + /** 真实客户及计划托盘来自服务器来源,整页一次调用原Provider。 */ + private Map readCandidateBatches(List candidates) { + var identities=candidates.stream().filter(c->c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList(); + var result=new LinkedHashMap(); + for(int offset=0;offset batches) { + if(c.selection().planPalletId()==null)return c; + var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())); + var reasons=new LinkedHashSet(); + if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons()); + var refs=result==null?List.of():result.appointmentBatches().stream().map(b->new BatchReference(b.batchKey(),b.batchNo(),b.customerId(), + b.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),b.recordVersion())).toList(); + return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),refs,List.copyOf(reasons),c.selectionGroup()); + } + /** 原结果完整保存,异常时随所有业务写入一起回滚。 */ + private T complete(DispatchDraftCommand command,T result){repository.complete(command.getId(),write(result));return result;} + /** 原批结果的每张车使用公共Action编解码器恢复,不能让抽象Payload触发当前快照或重新创建回退。 */ + private ConvertedDraft readConverted(String stored,String expectedToken) { + try { + var root=json.readTree(stored); + if(root==null || !root.isObject() || !root.hasNonNull("draftToken") || !root.hasNonNull("vehicles") + || !root.get("vehicles").isArray() || root.get("vehicles").isEmpty() || !root.hasNonNull("replayed") + || !root.get("replayed").isBoolean())throw new IllegalArgumentException("原转换结果字段不完整"); + String token=json.treeToValue(root.get("draftToken"),String.class); + if(!Objects.equals(token,expectedToken))throw new IllegalArgumentException("原转换结果不属于本草稿"); + var vehicles=new ArrayList();var ids=new HashSet(); + for(var vehicle:root.get("vehicles")) { + if(!vehicle.isObject() || !vehicle.hasNonNull("draftContainerId") || !vehicle.hasNonNull("dispatch"))throw new IllegalArgumentException("原转换车辆字段不完整"); + Long id=json.treeToValue(vehicle.get("draftContainerId"),Long.class); + if(id==null || id<=0 || !ids.add(id))throw new IllegalArgumentException("原转换容器身份不完整"); + var dispatch=commandCodec.decode(vehicle.get("dispatch").toString()); + if(dispatch.action()!=DispatchTypes.Action.CREATE)throw new IllegalArgumentException("原转换车辆动作不是创建"); + vehicles.add(new ConvertedVehicle(id,dispatch)); + } + // 仅批次返回标记为重放,子车仍是不可变原结果,原版本/时间/载荷逐项保留。 + return new ConvertedDraft(token,List.copyOf(vehicles),true); + }catch(JsonProcessingException | IllegalArgumentException error){throw new IllegalStateException("草稿转换原结果损坏",error);} + } + /** 复用配置好的时间和Long序列化,持久结果不得保留内存对象引用。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("草稿结果保存失败",error);}} + /** 进程重启后从不可变JSON恢复原公开结果。 */ + private T read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("草稿结果损坏",error);}} + /** 来源和布局使用封闭类型读取,非法存储不能按空草稿降级。 */ + private T read(String value,TypeReference type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("草稿来源损坏",error);}} + /** 草稿只属于平台操作者,客户和PDA不能复用整车写入口。 */ + private static void platform(Actor actor){if(actor==null || actor.operatorId()==null || actor.operatorId().isBlank() || actor.mode()!=DispatchTypes.ActorMode.PLATFORM)throw new BizException(ResultCode.FORBIDDEN,"仅平台操作者可操作草稿");} + /** 存储时间统一为UTC,本地进程时区不改变过期或幂等身份。 */ + private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS),ZoneOffset.UTC);} + /** 等待锁、续约或写入后仍须在原有效期内,不能在过期后复活占用再返回成功。 */ + private void requireLease(LocalDateTime deadline){if(deadline==null || !deadline.isAfter(now()))throw conflict("草稿已过期,请重新创建并核对来源");} + /** 参数错误采用统一业务结果码。 */ + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} + /** 状态或依据变化不能被解释为新的成功命令。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java new file mode 100644 index 000000000..9aeb1e526 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java @@ -0,0 +1,158 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** 锁内冻结明确组托贡献,后续扫码只消费这些真实额度,不对混合仓位做FIFO猜归属。 */ +@Service +public class DispatchItemSourceService { + private final DispatchItemSourceRepository repository; + private final RelabelDispatchSourcePort relabel; + private final ObjectMapper json; + /** 来源事实经提供者读取,冻结贡献及已消费数经本域仓储读取。 */ + public DispatchItemSourceService(DispatchItemSourceRepository repository,RelabelDispatchSourcePort relabel,ObjectMapper json){this.repository=repository;this.relabel=relabel;this.json=json;} + + /** packages是本次实际消费量,件数未知保留空;不是请求可伪造的来源身份。 */ + public record Allocation(Long dispatchItemSourceId,long packages,Long productQuantity) { } + + /** 主单原事务已锁齐全部来源与库存;所有当前成员一起核对,仅为新增明细追加不可变快照。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void capture(DispatchAggregate root,List all,List added,LocalDateTime at) { + var normal=all.stream().filter(this::normalRelabel).toList(); + if(normal.isEmpty())return; + var snapshot=relabel.lockedSnapshot(root.getOverseasWarehouseId(),normal.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList()); + if(snapshot==null || !snapshot.complete())throw conflict("换标实托真实来源组成尚未核齐"); + var selected=new HashMap(); + for(var item:normal)if(selected.put(key(item.getSourceBusinessNo(),item.getWarehousePalletId()),item)!=null)throw conflict("本车同一换标来源重复"); + var applications=snapshot.applications().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Application::applicationId,a->a)); + var positions=snapshot.positions().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Position::positionId,p->p)); + var totals=new HashMap();var held=new HashMap();var identities=new HashSet(); + var additions=added.stream().map(DispatchItem::getId).collect(Collectors.toSet()); + for(var source:snapshot.sources()) { + var item=selected.get(key(source.relabelNo(),source.warehousePalletId()));var app=applications.get(source.applicationId());var position=positions.get(source.inventoryPositionId()); + if(item==null)throw conflict("实托或完整换标申请还有未选入本车的实际贡献"); + if(app==null || position==null || !Objects.equals(app.customerId(),source.customerId()) || !Objects.equals(app.fulfillmentOrderId(),item.getOrderId()) + || !Objects.equals(item.getCustomerId(),source.customerId()) || !Objects.equals(root.getOverseasWarehouseId(),source.warehouseId()) + || !Objects.equals(position.customerId(),source.customerId()) || !Objects.equals(position.warehousePalletId(),source.warehousePalletId()) + || !Objects.equals(position.inventoryBalanceId(),source.inventoryBalanceId()) || source.boundPackageCount()<=0 || source.notifiedPackageCount()<0 + || source.productQuantity()!=null && source.productQuantity()<0 || source.bindingBasisHash()==null || !source.bindingBasisHash().matches("[a-f0-9]{64}") + || source.unitFacts()==null)throw conflict("换标原货源、实际位置或绑定依据不完整"); + if(!identities.add(source.applicationId()+":"+source.itemId()+":"+source.inventoryPositionId()))throw conflict("换标实际贡献重复,不能重复计算冻结箱数"); + totals.merge(item.getId(),source.boundPackageCount(),Math::addExact);held.merge(source.inventoryPositionId(),source.boundPackageCount(),Math::addExact); + if(!additions.contains(item.getId()))continue; + var row=new DispatchItemSource();row.setId(IdWorker.getId());row.setDispatchOrderId(root.getId());row.setDispatchItemId(item.getId());row.setCustomerId(source.customerId()); + if(app.roundKey()==null || app.roundKey().isBlank())throw conflict("换标实际作业轮次缺失"); + row.setRelabelApplicationId(source.applicationId());row.setRelabelItemId(source.itemId());row.setRelabelRoundKey(app.roundKey());row.setWarehousePalletId(source.warehousePalletId()); + row.setInventoryPositionId(source.inventoryPositionId());row.setInventoryBalanceId(source.inventoryBalanceId());row.setSourceInventoryBalanceId(source.sourceInventoryBalanceId()); + row.setSourceInboundOrderId(source.sourceInboundOrderId());row.setSourceInboundOrderNo(source.sourceInboundOrderNo());row.setSourceInboundCode(source.sourceInboundCode()); + row.setNotifiedPackageCount(source.notifiedPackageCount());row.setPackageCount(source.boundPackageCount());row.setProductQuantity(source.productQuantity()); + row.setBindingBasisHash(source.bindingBasisHash());row.setUnitFactsJson(write(source.unitFacts()));row.setCreatedAt(at); + if(repository.insert(row)!=1)throw conflict("换标实际贡献未保存"); + } + for(var item:normal)if(totals.getOrDefault(item.getId(),0L)!=item.getPackageCount().longValue())throw conflict("本换标来源箱数与逐项实际贡献不一致"); + for(var position:snapshot.positions())if(held.getOrDefault(position.positionId(),0L)!=position.holdPackages())throw conflict("实托累计换标贡献与真实冻结箱数不一致"); + } + + /** 正常扫码可改变状态和库存版本,但不得改变轮次、原货源、位置或逐件作业事实。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void validateFrozen(DispatchAggregate root,List items,RelabelDispatchSourcePort.Snapshot current) { + var normal=items.stream().filter(this::normalRelabel).collect(Collectors.toMap(DispatchItem::getId,i->i)); + var nativeNos=items.stream().filter(i->"RETURN_RELABEL".equals(i.getSourceStage())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet()); + var frozen=repository.all(root.getId());var expected=new HashMap(); + for(var row:frozen) { + var item=normal.get(row.getDispatchItemId()); + if(item==null || !Objects.equals(item.getCustomerId(),row.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),row.getWarehousePalletId()))throw conflict("本车冻结贡献与逻辑来源不一致"); + if(expected.put(row.getRelabelApplicationId()+":"+row.getRelabelItemId()+":"+row.getInventoryPositionId(),row)!=null)throw conflict("本车冻结贡献身份重复"); + } + var apps=current.applications().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Application::applicationId,a->a)); + for(var source:current.sources()) { + if(nativeNos.contains(source.relabelNo()))continue; + var row=expected.remove(source.applicationId()+":"+source.itemId()+":"+source.inventoryPositionId()); + if(row==null)throw conflict("实托出现未经本车审核的换标贡献"); + var app=apps.get(source.applicationId());var item=normal.get(row.getDispatchItemId()); + if(app==null || !Objects.equals(app.fulfillmentOrderId(),item.getOrderId()) || !Objects.equals(app.relabelNo(),item.getSourceBusinessNo()) + || !Objects.equals(app.roundKey(),row.getRelabelRoundKey()) || !"READY_FOR_OUTBOUND".equals(app.status()) + || !"PRE_DISPATCH_CREATED".equals(app.fulfillmentStatus()) || !"TRUCK".equals(app.deliveryMethod()) + || !Objects.equals(source.customerId(),row.getCustomerId()) || !Objects.equals(source.warehouseId(),root.getOverseasWarehouseId()) + || !Objects.equals(source.warehousePalletId(),row.getWarehousePalletId()) || !Objects.equals(source.inventoryBalanceId(),row.getInventoryBalanceId()) + || !Objects.equals(source.sourceInventoryBalanceId(),row.getSourceInventoryBalanceId()) || !Objects.equals(source.sourceInboundOrderId(),row.getSourceInboundOrderId()) + || !Objects.equals(source.sourceInboundOrderNo(),row.getSourceInboundOrderNo()) || !Objects.equals(source.sourceInboundCode(),row.getSourceInboundCode()) + || source.notifiedPackageCount()!=row.getNotifiedPackageCount() || !Objects.equals(source.productQuantity(),row.getProductQuantity()) + || !sameFacts(source.unitFacts(),row.getUnitFactsJson()))throw conflict("换标轮次、实际位置或原货源依据已变化,请回平台核对"); + } + if(!expected.isEmpty() || !normal.isEmpty() && frozen.isEmpty())throw conflict("本车原冻结换标贡献缺失"); + var totals=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId,Collectors.summingLong(DispatchItemSource::getPackageCount))); + for(var item:normal.values())if(totals.getOrDefault(item.getId(),0L)!=item.getPackageCount().longValue())throw conflict("换标逐项冻结箱数尚未核平"); + var used=repository.pickedAll(List.of(root.getId())).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())); + var remaining=new HashMap(); + for(var row:frozen) { + long left=row.getPackageCount()-used.getOrDefault(row.getId(),0L); + if(left<0)throw conflict("换标实际消费已超过冻结贡献");remaining.merge(row.getInventoryPositionId(),left,Math::addExact); + } + for(var position:current.positions())if(position.holdPackages()!=remaining.getOrDefault(position.positionId(),0L))throw conflict("实托全部换标剩余贡献与实际冻结箱数不一致"); + } + + /** 比较原逐件事实,不把JSON排版当作来源变化。 */ + private boolean sameFacts(Object facts,String frozen){try{return json.readTree(write(facts)).equals(json.readTree(frozen));}catch(Exception error){throw new IllegalStateException("冻结逐件事实无法读取",error);}} + + /** 本客户本实托的全部剩余贡献必须累计核对HOLD,逐item单独足够不能证明共享position总量足够。 */ + @Transactional(propagation=Propagation.MANDATORY) + public List allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List actualStock) { + var all=repository.all(dispatch);var consumed=repository.pickedAll(List.of(dispatch)).stream().collect(Collectors.toMap(p->p.sourceId(),p->p)); + var expected=new HashMap(); + for(var source:all)if(Objects.equals(item.getCustomerId(),source.getCustomerId()) && Objects.equals(item.getWarehousePalletId(),source.getWarehousePalletId())) { + long picked=consumed.containsKey(source.getId())?consumed.get(source.getId()).packages():0; + if(picked<0 || picked>source.getPackageCount())throw conflict("换标实际消费额度已超过冻结贡献"); + expected.merge(source.getInventoryPositionId(),source.getPackageCount()-picked,Math::addExact); + } + var stock=actualStock.stream().collect(Collectors.toMap(DispatchSourceMapper.Stock::id,s->s)); + for(var actual:actualStock)if(actual.holdPackages()!=expected.getOrDefault(actual.id(),0L))throw conflict("同实托累计换标剩余份额与实际冻结箱数不一致"); + for(var entry:expected.entrySet())if(entry.getValue()>0 && !stock.containsKey(entry.getKey()))throw conflict("换标实际来源位置已变化"); + var rows=all.stream().filter(s->Objects.equals(item.getId(),s.getDispatchItemId()) && Objects.equals(item.getCustomerId(),s.getCustomerId()) + && Objects.equals(positionId,s.getInventoryPositionId())).filter(s->s.getPackageCount()>(consumed.containsKey(s.getId())?consumed.get(s.getId()).packages():0)) + .filter(s->cartonMark==null || cartonMark.isBlank() || matches(s,cartonMark.trim())).toList(); + if(rows.isEmpty() || !stock.containsKey(positionId) || rows.stream().anyMatch(s->!Objects.equals(stock.get(positionId).balanceId(),s.getInventoryBalanceId())))throw conflict("所选位置或箱唛不属于本换标来源的剩余贡献"); + var remaining=new HashMap();long total=0; + for(var row:rows){long left=row.getPackageCount()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).packages():0);remaining.put(row.getId(),left);total=Math.addExact(total,left);} + if(packages<=0 || packages>total)throw conflict("实际扫码超过本换标来源本位置的剩余箱数"); + if(rows.size()>1 && packages!=total)throw conflict("本位置含多条换标贡献,请扫描具体箱唛后确认部分箱数"); + var result=new ArrayList();long knownQuantity=0; + for(var row:rows)if(row.getProductQuantity()!=null){long used=consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0;long left=row.getProductQuantity()-used;if(left<0)throw conflict("换标实际件数超过来源贡献");knownQuantity=Math.addExact(knownQuantity,left);} + if(rows.size()>1) { + if(productQuantity!=knownQuantity)throw conflict("多来源件数不能推算,请扫描明确箱唛并确认实际件数"); + for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new Allocation(row.getId(),remaining.get(row.getId()),quantity));} + } else { + var row=rows.get(0);if(row.getProductQuantity()!=null && (productQuantity>knownQuantity || packages==total && productQuantity!=knownQuantity))throw conflict("实际扫码件数与本来源剩余件数不一致"); + result.add(new Allocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity)); + } + return List.copyOf(result); + } + + /** 移货只删除未产生实际拣货的原贡献;取消保留快照作历史。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void removeUnpicked(long dispatch,long item){repository.removeUnpicked(dispatch,item);} + /** 普通换标和原生退货箱始终使用不同真实来源分支。 */ + private boolean normalRelabel(DispatchItem item){return "RELABEL_ORDER".equals(item.getSourceBusinessType()) && "RELABEL".equals(item.getSourceStage());} + /** 组内身份由真实换标业务号与全局实托共同确定。 */ + private String key(String number,Long pallet){return number+":"+pallet;} + /** 箱唛只在所选应用/位置范围内解析,不用另一个应用的同托记录冒充。 */ + private boolean matches(DispatchItemSource source,String mark){ + try {for(var fact:json.readTree(source.getUnitFactsJson()))if(mark.equalsIgnoreCase(fact.path("oldLabel").asText()) || mark.equalsIgnoreCase(fact.path("newLabel").asText()))return true;return false;} + catch(Exception failure){throw new IllegalStateException("冻结换标工作事实无法读取",failure);} + } + /** 原工作事实完整持久化,不把成功数或SKU件数改写成通知箱数。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception failure){throw new IllegalStateException("换标实际贡献无法保存",failure);}} + /** 任一来源不一致均由原主单事务整体回滚。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java new file mode 100644 index 000000000..add8a5eae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchPdaReadRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** PDA按绑定仓读取真正整车,列表批量装配全部明细而不逐行请求详情。 */ +@Service +public class DispatchPdaQueryService { + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins; + private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow; + /** 共用主流程资格,PDA不维护第二套版本和阶段。 */ + public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;} + /** 列表只展示绑定仓实际作业,阶段使用共同枚举。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public List list(Actor actor,Stage stage){require(actor);return assemble(actor,repository.headers(actor.boundWarehouseId(),null,stage==null?null:stage.name()));} + /** 单车也先在SQL约束绑定仓,其他仓业务编号不可用于读取。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.PdaTask task(Actor actor,String number) { + require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); + var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); + } + /** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */ + private List assemble(Actor actor,List roots) { + if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号"); + var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId)); + var picked=repository.picked(ids).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var invalid=repository.invalid(ids).stream().collect(Collectors.toMap(p->p.dispatchId(),p->p.quantity())); + var positions=repository.positions(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(p->p.getCustomerId()+":"+p.getPalletId())); + var cartons=repository.cartons(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(c->c.itemId()));var result=new ArrayList(); + boolean hasRelabel=cargo.values().stream().flatMap(Collection::stream).anyMatch(DispatchPdaQueryService::normalRelabel); + var frozen=hasRelabel?sourceOrigins.batchAll(ids):List.of(); + var byItem=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId)); + var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.of(); + for(var root:roots) { + var items=cargo.getOrDefault(root.getId(),List.of());var detail=new ArrayList(); + boolean sourceValid=true;var expectedHold=new HashMap(); + for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) { + long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used; + if(used<0 || left<0)sourceValid=false; + expectedHold.merge(source.getCustomerId()+":"+source.getWarehousePalletId()+":"+source.getInventoryPositionId(),left,Math::addExact); + } + if(Stage.LOADING.name().equals(root.getStage())) { + for(var group:positions.values())for(var p:group) { + String key=p.getCustomerId()+":"+p.getPalletId()+":"+p.getId(); + if(items.stream().anyMatch(i->normalRelabel(i) && Objects.equals(i.getCustomerId(),p.getCustomerId()) && Objects.equals(i.getWarehousePalletId(),p.getPalletId())) + && n(p.getHoldPackages())!=expectedHold.getOrDefault(key,0L))sourceValid=false; + } + } + for(var item:items) { + var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet()); + var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream() + .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); + if(normalRelabel(item)) { + var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList(); + if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false; + for(var group:contributions.stream().collect(Collectors.groupingBy(DispatchItemSource::getInventoryPositionId,TreeMap::new,Collectors.toList())).entrySet()) { + long used=group.getValue().stream().mapToLong(s->consumed.getOrDefault(s.getId(),0L)).sum(); + long total=group.getValue().stream().mapToLong(s->n(s.getPackageCount())).sum(); + var actual=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream().filter(p->Objects.equals(p.getId(),group.getKey())).findFirst().orElse(null); + if(actual==null || group.getValue().stream().anyMatch(s->!Objects.equals(s.getCustomerId(),item.getCustomerId()) || !Objects.equals(s.getWarehousePalletId(),item.getWarehousePalletId()) || !Objects.equals(s.getInventoryBalanceId(),actual.getBalanceId()))) { + if(total>used)sourceValid=false;continue; + } + ownPositions.add(new DispatchViews.Position(actual.getId(),actual.getBalanceId(),Math.max(0,total-used),0,used)); + } + sourcePositions=List.copyOf(ownPositions); + } + var sourceCartons=cartons.getOrDefault(item.getId(),List.of()).stream().filter(c->marks.contains(c.cartonMark())) + .map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList(); + detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons,item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo())); + } + boolean complete=sourceValid && n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0 + && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())); + var actions=new EnumMap(workflow.actions(actor,root)); + if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对")); + if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平")); + else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); + var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); + // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 + result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); + } + return List.copyOf(result); + } + /** 仓和操作者均来自受信身份,不接收请求自报。 */ + private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} + /** 缺失计数不作为业务标识使用。 */ + private static long n(Number value){return value==null?0:value.longValue();} + /** 原生退货箱沿真实箱账本,不把它当普通仓位贡献。 */ + private static boolean normalRelabel(DispatchItem item){return "RELABEL".equals(item.getSourceStage()) && "RELABEL_ORDER".equals(item.getSourceBusinessType());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java new file mode 100644 index 000000000..f2c473127 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java @@ -0,0 +1,140 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock; +import com.baiyue.lastleg.dispatch.repository.DispatchPhysicalClaimRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.LocalDateTime; +import java.util.*; + +/** 同托成员共享一个真实拥有者;所有方法跟随主单或草稿原事务,不独立提交。 */ +@Service +public class DispatchPhysicalClaimService { + private final DispatchPhysicalClaimRepository repository; + /** 物理占用不依赖任何客户代表行。 */ + public DispatchPhysicalClaimService(DispatchPhysicalClaimRepository repository){this.repository=repository;} + + /** 正式拥有者和草稿拥有者互斥,草稿身份还必须包含原容器和原操作人。 */ + public record Owner(String warehouseId,Long dispatchId,String draftToken,Long draftContainerId,String operatorId,LocalDateTime expiresAt) { + /** 不允许混合或缺失拥有者字段,空计划来源不会申请物理占用。 */ + public Owner { + if(warehouseId==null || warehouseId.isBlank() || operatorId==null || operatorId.isBlank() + || !(dispatchId!=null && dispatchId>0 && draftToken==null && draftContainerId==null && expiresAt==null + || dispatchId==null && draftToken!=null && !draftToken.isBlank() && draftContainerId!=null && draftContainerId>0 && expiresAt!=null)) + throw new IllegalArgumentException("物理实托拥有者身份不完整"); + } + } + + /** 调用方先锁齐申请、来源及库存,再按实托全局顺序取得排他占用。 */ + @Transactional(propagation=Propagation.MANDATORY) + public Map reserve(Owner owner,Collection pallets,LocalDateTime at) { + if(at==null || owner.expiresAt()!=null && !owner.expiresAt().isAfter(at))throw conflict("草稿占用已到期,请重新核对"); + var result=new LinkedHashMap(); + for(var pallet:ordered(pallets)) { + var row=repository.lockActive(pallet); + if(row!=null && !Objects.equals(row.getWarehouseId(),owner.warehouseId()))throw conflict("实托占用起运仓不一致,请核对来源"); + if(row!=null && row.getDispatchOrderId()==null && row.getExpiresAt()!=null && !row.getExpiresAt().isAfter(at)) { + repository.expireMembers(row.getId(),at); + if(repository.releaseUnused(row.getId(),"EXPIRED",at)!=1)throw conflict("原实托成员尚未完整到期"); + row=null; + } + if(row!=null) { + if(!owns(row,owner))throw conflict("所选实托已被其他有效运输安排占用"); + } else { + row=new DispatchPhysicalPalletLock();row.setId(IdWorker.getId());row.setWarehouseId(owner.warehouseId()); + row.setWarehousePalletId(pallet);row.setActiveWarehousePalletId(pallet);row.setDispatchOrderId(owner.dispatchId()); + row.setDraftToken(owner.draftToken());row.setDraftContainerId(owner.draftContainerId());row.setOperatorId(owner.operatorId()); + row.setStatus("ACTIVE");row.setExpiresAt(owner.expiresAt());row.setLockedAt(at);row.setCreatedAt(at);row.setUpdatedAt(at); + try{if(repository.insert(row)!=1)throw conflict("实托占用未保存");} + catch(DuplicateKeyException error){throw conflict("所选实托已被并发运输安排占用,请重新核对");} + } + result.put(pallet,row.getId()); + } + return Collections.unmodifiableMap(result); + } + + /** 只结束原主键、本拥有者且没有活动成员的占用;其他来源仍在时保留物理排他。 */ + @Transactional(propagation=Propagation.MANDATORY) + public int releaseIfUnused(Owner owner,Collection originalIds,LocalDateTime at) { + Objects.requireNonNull(at,"释放时间缺失"); + var original=new ArrayList(); + // 先普通读取不可变实托身份,再按实托逐原主键取锁,不依赖ORDER BY之前的数据库扫描锁序。 + var references=repository.originals(ordered(originalIds)).stream() + .sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList(); + for(var ref:references) { + var row=repository.lockOriginal(ref.getId()); + if(row==null || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原物理占用身份已变化"); + if("ACTIVE".equals(row.getStatus()) && owns(row,owner))original.add(row); + } + int released=0; + for(var row:original)released+=repository.releaseUnused(row.getId(),"RELEASED",at); + return released; + } + + /** 按拥有者普通读原身份,再按实际实托逐主键取锁,不依赖批量扫描计划的加锁先后。 */ + @Transactional(propagation=Propagation.MANDATORY) + public List lockOwner(Owner owner) { + var references=owner.dispatchId()!=null?repository.lockDispatch(owner.dispatchId()):repository.lockDraft(owner.draftToken(),owner.operatorId()); + var result=new ArrayList(); + for(var ref:references.stream().filter(r->owner.dispatchId()!=null || Objects.equals(r.getDraftContainerId(),owner.draftContainerId())) + .sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList()) { + var row=repository.lockOriginal(ref.getId()); + if(row==null || !"ACTIVE".equals(row.getStatus()) || !owns(row,owner) || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原实托占用归属已变化"); + result.add(row); + } + return List.copyOf(result); + } + + /** 逻辑成员须已全部移入目标容器;物理拥有者仍按原容器及原租约核对。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void moveDraft(Owner previous,Collection originalPhysicalLockIds,long nextContainerId,LocalDateTime at) { + if(previous.dispatchId()!=null || nextContainerId<=0 || at==null || !previous.expiresAt().isAfter(at))throw conflict("草稿整托迁移身份或租约无效"); + var ids=ordered(originalPhysicalLockIds);var references=repository.originals(ids).stream().sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList(); + if(ids.isEmpty() || references.size()!=ids.size())throw conflict("原草稿容器没有完整可迁移的实托占用"); + var rows=new ArrayList(); + for(var ref:references) { + var row=repository.lockOriginal(ref.getId()); + if(row==null || !"ACTIVE".equals(row.getStatus()) || !owns(row,previous) || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原草稿实托拥有者已变化"); + rows.add(row); + } + for(var row:rows) { + if(!Objects.equals(row.getExpiresAt(),previous.expiresAt()))throw conflict("原草稿实托租约已变化"); + if(repository.moveDraft(row.getId(),previous.draftToken(),previous.operatorId(),previous.draftContainerId(),nextContainerId,at)!=1)throw conflict("同实托全部逻辑来源尚未移入同一目标容器"); + } + } + + /** 调用方先持草稿根和完整共同实托锁,物理续期后再同事务续期逻辑成员及根。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void renewDraft(String warehouseId,String draftToken,String operatorId,LocalDateTime at,LocalDateTime previousExpiresAt,LocalDateTime nextExpiresAt) { + if(warehouseId==null || warehouseId.isBlank() || draftToken==null || draftToken.isBlank() || operatorId==null || operatorId.isBlank() + || at==null || previousExpiresAt==null || nextExpiresAt==null || !previousExpiresAt.isAfter(at) || nextExpiresAt.isBefore(previousExpiresAt))throw conflict("草稿续期身份或租约无效"); + var references=repository.lockDraft(draftToken,operatorId).stream().sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList(); + for(var ref:references) { + var row=repository.lockOriginal(ref.getId()); + if(row==null || !"ACTIVE".equals(row.getStatus()) || row.getDispatchOrderId()!=null || row.getDraftContainerId()==null + || !Objects.equals(row.getWarehouseId(),warehouseId) || !Objects.equals(row.getDraftToken(),draftToken) || !Objects.equals(row.getOperatorId(),operatorId) + || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()) || !Objects.equals(row.getExpiresAt(),previousExpiresAt))throw conflict("草稿实托归属或原租约已变化"); + } + if(nextExpiresAt.equals(previousExpiresAt))return; + if(repository.renewDraft(draftToken,operatorId,at,nextExpiresAt)!=references.size())throw conflict("草稿实托续期未完整保存"); + } + + /** 身份核对不把正式排车创建操作人当拥有者,其他有权限人员仍可维护同一主单。 */ + private boolean owns(DispatchPhysicalPalletLock row,Owner owner) { + return Objects.equals(row.getWarehouseId(),owner.warehouseId()) && Objects.equals(row.getDispatchOrderId(),owner.dispatchId()) + && Objects.equals(row.getDraftToken(),owner.draftToken()) && Objects.equals(row.getDraftContainerId(),owner.draftContainerId()) + && (owner.dispatchId()!=null || Objects.equals(row.getOperatorId(),owner.operatorId())); + } + + /** 实托和原占用引用必须真实且去重,固定顺序避免逐客户交错取锁。 */ + private List ordered(Collection ids) { + if(ids==null || ids.stream().anyMatch(id->id==null || id<=0))throw new IllegalArgumentException("真实实托或占用引用缺失"); + return List.copyOf(new TreeSet<>(ids)); + } + /** 任一物理争用失败都由外层事务回滚整组逻辑来源。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java new file mode 100644 index 000000000..4f5fc7da3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -0,0 +1,140 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ +@Service +public class DispatchPickingService { + @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; + @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; + @jakarta.annotation.Resource private DispatchService sourceRules; + private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; + private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; + private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; + private final DispatchCommandLedger commands;private final DispatchCommandHasher hashes;private final ObjectMapper json; + /** 库存命令与来源归属继续使用既有真实生产者,不用请求数量伪造库存。 */ + public DispatchPickingService(DispatchAggregateService workflow,DispatchAggregateRepository repository,DispatchSourceRepository sources, + DispatchPickingRepository picking,DispatchPickOrderAttributionService attribution,InventoryCommandService inventory, + ReturnRelabelDispatchStock returns,DispatchCommandLedger commands,DispatchCommandHasher hashes,ObjectMapper json) { + this.workflow=workflow;this.repository=repository;this.sources=sources;this.picking=picking;this.attribution=attribution;this.inventory=inventory;this.returns=returns;this.commands=commands;this.hashes=hashes;this.json=json; + } + /** 原键恢复先于当前版本/阶段,全部写入跟随同一根事务回滚。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult pick(Actor actor,String number,long itemId,DispatchCommands.Pick request) { + if(actor==null || actor.mode()!=ActorMode.PDA || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可扫码"); + var context=workflow.lockForArrangement(actor,number);var row=repository.lock(number); + var identity=new CommandIdentity(Action.PICK,request.idempotencyKey(),request.recordVersion(),hashes.command(actor,Action.PICK,number,request.recordVersion(),Arrays.asList(itemId,request))); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING + || context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码"); + if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法"); + try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");} + var currentItems=repository.cargo(row.getId()); + var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在")); + if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓"); + materialize(row,item,actor,currentItems); + var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC); + if(task==null) { + task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId()); + task.setTaskNo(number); + task.setStatus("PICKING");task.setWarehouseId(actor.boundWarehouseId());task.setPushedAt(at);task.setCreatedAt(at);task.setUpdatedAt(at); + if(picking.createTask(task)!=1)throw conflict("本车作业未保存"); + } + if(!Objects.equals(task.getWarehouseId(),actor.boundWarehouseId()))throw conflict("本车作业仓已变化"); + long before=picking.itemPicked(item.getCustomerId(),task.getId(),itemId); + if(request.packages()>item.getPackageCount()-before)throw conflict("实际扫码超过本来源应装箱数"); + boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage()); + if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); + long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType()); + List relabelAllocations=List.of(); + if(nativeReturn) { + attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); + returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); + } else { + var position=picking.position(request.positionId()); + if(position==null || !Objects.equals(item.getCustomerId(),position.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),position.getPalletId()) || !Objects.equals(actor.boundWarehouseId(),position.getOverseasWarehouseId()))throw conflict("实际库存仓位不属于本车本客户实托"); + if(ordinaryRelabel)attribution.prepareRelabel(item.getCustomerId(),item.getOrderId()); + else attribution.preparePicking(item.getCustomerId(),request.positionId()); + var stock=sources.stock(item.getCustomerId(),item.getWarehousePalletId(),true); + var actual=stock.stream().filter(s->s.id()==request.positionId()).findFirst().orElseThrow(()->conflict("实际库存仓位已移动")); + if(!Objects.equals(actual.warehouseId(),actor.boundWarehouseId()))throw conflict("实际库存起运仓已变化"); + if(ordinaryRelabel)relabelAllocations=relabelSources.allocate(row.getId(),item,request.positionId(),request.packages(),request.productQuantity(),request.cartonMark(),stock); + String base="DISPATCH:"+number+":"+itemId+":"+request.idempotencyKey(); + if(ordinaryRelabel)inventory.release(item.getCustomerId(),actor.operatorId(),move(request,number,item.getSourceBusinessNo(),base+":RELABEL_RELEASE")); + inventory.allocate(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":ALLOCATE")); + inventory.pick(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":PICK")); + inventory.ship(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":SHIP")); + } + var pick=new DispatchPdaPick();pick.setId(pickId);pick.setCustomerId(item.getCustomerId());pick.setTaskId(task.getId());pick.setDispatchItemId(itemId); + pick.setInventoryPositionId(request.positionId());pick.setReturnCartonId(returnCartonId);pick.setPackages(request.packages());pick.setProductQuantity(request.productQuantity());pick.setIdempotencyKey(request.idempotencyKey());pick.setPickedAt(at);pick.setCreatedAt(at); + if(picking.pick(pick)!=1)throw conflict("真实扫码明细未保存"); + if(nativeReturn)attribution.attributeNativeReturn(item.getCustomerId(),pick,item.getOrderId()); + else if(ordinaryRelabel)attribution.attributeRelabel(item.getCustomerId(),pick,item.getOrderId(),relabelAllocations); + else attribution.attribute(item.getCustomerId(),pick,item.getReturnReceiptId()); + task.setOperatorId(actor.operatorId());task.setStatus("PICKING");task.setUpdatedAt(at);if(picking.saveTask(task)!=1)throw conflict("本车作业进度未保存"); + long version=row.getRecordVersion();row.setPickedPackageCount(Math.toIntExact(repository.realPicked(row.getId()))); + row.setPalletCount(Math.toIntExact(currentItems.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); + row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); + if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败"); + boolean complete=workflow.preview(actor,number).pickingReconciled(); + if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存"); + var result=new CommandResult(number,Action.PICK,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.LOADING,at.toInstant(ZoneOffset.UTC),Effect.APPLIED, + new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */ + private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List items) { + long dispatch=row.getId(); + if(item.getWarehousePalletId()!=null) { + relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return; + } + if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托"); + var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); + if(orders.isEmpty())throw conflict("原计划缺少真实订单组成"); + var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0); + if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) + || !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType()) + || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对"); + if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单"); + item.setWarehousePalletId(actual.warehousePalletId()); + relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); + if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对"); + var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true); + var pallets=new ArrayList();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId()); + var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId()); + try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");} + catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");} + item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); + if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存"); + } + /** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */ + private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());} + /** 不可变扫码证据序列化失败随根命令回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("扫码证据保存失败",error);}} + /** 实际来源或版本冲突不能降级为成功。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} + /** 明确请求值错误。 */ + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,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 index fd2359dfe..daa0b64f7 100644 --- 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 @@ -1,88 +1,468 @@ 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.entity.DispatchDraft; -import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; -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.mapper.DispatchDraftMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper; -import com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse; -import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; -import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; -import com.baiyue.lastleg.relabel.service.RelabelApplicationService; 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 com.baiyue.lastleg.service.LastlegOrderNoGenerator; -import java.util.Arrays; -import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import java.util.*; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; @Service public class DispatchService { - @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; - @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workChanges; - public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; - public static final String CANCELLED = "CANCELLED"; + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository wholeSources; + @Resource private DispatchCommandHasher wholeHashes; + @Resource private RelabelDispatchSourcePort relabelSources; + @Resource private com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort palletSerial; + private final Object relabelLockScope=new Object(); - @Resource private DispatchOrderMapper orders; - @Resource private DispatchItemMapper items; - @Resource private DispatchBolMapper bolLines; - @Resource private DispatchPalletLockMapper locks; - @Resource private DispatchDraftMapper drafts; - @Resource private DispatchDraftContainerMapper draftContainers; - @Resource private DispatchCandidateMapper candidates; - @Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; + /** 候选与写入共用完整来源集合,展示筛选不参与组成推导。 */ + private record SourceGroup(SelectionGroup view,Map rows,Map versions) { } - public String resolveCustomerScopeForSources(List planPalletIds, List returnReceiptIds) { - var scopes = new java.util.ArrayList(); - if (planPalletIds != null && !planPalletIds.isEmpty()) scopes.addAll(candidates.findCustomerScopesByPlanPalletIds(planPalletIds)); - if (returnReceiptIds != null && !returnReceiptIds.isEmpty()) scopes.addAll(returnCandidates.customers(returnReceiptIds)); - return requireSingleCustomerScope(scopes.stream().distinct().toList(), "Selected dispatch sources"); + /** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */ + public Map selectionGroups(List rows) { + var result=new LinkedHashMap(); + for(var row:rows) { + String key=wholeSourceKey(selection(row,null)); + if(result.containsKey(key))continue; + var group=sourceGroup(row,false); + for(var source:group.view().sources())result.put(source.sourceKey(),group.view()); + } + return Collections.unmodifiableMap(result); } - @Resource private DispatchPdaTaskMapper pdaTasks; - @Resource private LastlegOrderNoGenerator orderNoGenerator; - @Resource private LastlegOrderMapper tailOrders; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private FleetDispatchBindingGuard fleetBindings; - @Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; + + /** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */ + public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) { + var row=readSelection(warehouse,requested);return sourceGroup(row,false).view(); + } + + /** 业务来源和物理托盘分别标识;来源版本由同一完整组事实派生。 */ + private DispatchCommands.CargoSelection selection(DispatchPalletCandidate row,String version) { + SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN; + return new DispatchCommands.CargoSelection(type,type==SourceType.INBOUND_PLAN?row.planPalletId():null,type==SourceType.RETURN_RECEIPT?row.returnReceiptId():null, + type==SourceType.RELABEL?row.orderRefs():null,row.warehousePalletId(),version); + } + + /** 只读定位请求真实来源,不在收齐申请/订单之前抢占任何库存锁。 */ + private DispatchPalletCandidate readSelection(String warehouse,DispatchCommands.CargoSelection request) { + wholeSourceKey(request);DispatchPalletCandidate row; + if(request.sourceType()==SourceType.INBOUND_PLAN)row=requireWholeCandidate(wholeSources.selectedPlan(requireSingleCustomerScope(wholeSources.planOwners(request.planPalletId()),"所选来源"),request.planPalletId())); + else if(request.sourceType()==SourceType.RETURN_RECEIPT)row=requireWholeCandidate(wholeSources.selectedReturn(requireSingleCustomerScope(wholeSources.returnOwners(request.returnReceiptId()),"所选退回来源"),request.returnReceiptId())); + else { + String customer=wholeSources.relabelOwner(request.relabelNo()); + if(customer==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"换标来源不存在"); + row=relabelRows(customer,request.relabelNo()).stream().filter(r->Objects.equals(request.warehousePalletId(),r.warehousePalletId())).findFirst() + .orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"换标托盘来源已变化")); + } + if(!Objects.equals(warehouse,row.overseasWarehouseId()))throw new ResponseStatusException(HttpStatus.CONFLICT,"所选来源起运仓不一致"); + return row; + } + + /** 普通和原生退货换标仍各走实际生产者,保留整申请全部应排托盘。 */ + private List relabelRows(String customer,String no) { + return java.util.stream.Stream.concat(measuredRelabelCandidates(mutations().relabelCandidates(customer,no)).stream(),returnRelabelStock.candidates(customer,null,no).stream()).toList(); + } + + /** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */ + private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) { + var rows=new TreeMap();rows.put(wholeSourceKey(selection(seed,null)),seed); + var pallets=new TreeSet();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId()); + var applications=new TreeMap();var loaded=new HashSet();var reasons=new LinkedHashSet(); + if(selection(seed,null).sourceType()==SourceType.RELABEL)applications.put(seed.orderRefs(),seed.customerId()); + RelabelDispatchSourcePort.Snapshot facts=null;var previous=new TreeSet(); + do { + previous.clear();previous.addAll(pallets); + if(!pallets.isEmpty()) { + facts=locked?relabelSources.lockedSnapshot(seed.overseasWarehouseId(),List.copyOf(pallets)):relabelSources.snapshot(seed.overseasWarehouseId(),List.copyOf(pallets)); + if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失"); + reasons.addAll(facts.blockReasons()); + if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐"); + facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add); + for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId()); + var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList(); + for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId()); + } + for(var app:new TreeMap<>(applications).entrySet())if(loaded.add(app.getKey())) { + var complete=relabelRows(app.getValue(),app.getKey()); + if(complete.isEmpty())reasons.add(app.getKey()+":申请尚无完整可排实托来源"); + for(var row:complete) { + if(!Objects.equals(seed.overseasWarehouseId(),row.overseasWarehouseId())){reasons.add("完整换标申请包含其他起运仓");continue;} + rows.put(wholeSourceKey(selection(row,null)),row);if(row.warehousePalletId()!=null)pallets.add(row.warehousePalletId()); + } + } + if(rows.size()>1000 || pallets.size()>1000)throw new ResponseStatusException(HttpStatus.CONFLICT,"完整来源组超过完整核验上限"); + } while(!pallets.equals(previous)); + var nativeNos=rows.values().stream().filter(r->"RETURN_RELABEL".equals(r.sourceStage())).map(DispatchPalletCandidate::orderRefs).collect(Collectors.toSet()); + // 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。 + reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":"))); + if(!pallets.isEmpty()) { + var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)); + if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限"); + var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList())); + for(var entry:buckets.entrySet()) { + var choices=entry.getValue().stream().sorted(Comparator.comparing(r->wholeSourceKey(selection(r,null)))).toList(); + var preferred=selection(seed,null).sourceType()!=SourceType.RELABEL && entry.getKey().equals(seed.customerId()+":"+seed.warehousePalletId())?seed: + choices.stream().filter(r->r.planPalletId()!=null).findFirst().orElse(null); + // PLAN是该客户此实托全部可用库存的一个选择别名,不能把多个别名重复计箱。 + if(preferred!=null && preferred.planPalletId()!=null)rows.put(wholeSourceKey(selection(preferred,null)),preferred); + else for(var row:choices)if(row.returnReceiptId()!=null)rows.put(wholeSourceKey(selection(row,null)),row); + } + if(facts!=null) { + var bound=facts.sources().stream().filter(s->!nativeNos.contains(s.relabelNo()) && s.inventoryPositionId()!=null).collect(Collectors.groupingBy(RelabelDispatchSourcePort.BoundSource::inventoryPositionId,Collectors.summingLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount))); + if(facts.positions().stream().anyMatch(p->p.holdPackages()!=bound.getOrDefault(p.positionId(),0L)))reasons.add("实托还有未核齐的实际冻结来源"); + var ordinary=rows.values().stream().filter(r->selection(r,null).sourceType()!=SourceType.RELABEL && r.warehousePalletId()!=null) + .collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId())); + var actual=facts.positions().stream().collect(Collectors.groupingBy(p->p.customerId()+":"+p.warehousePalletId(),Collectors.summingLong(RelabelDispatchSourcePort.Position::availablePackages))); + for(var entry:actual.entrySet())if(entry.getValue()>0 && ordinary.getOrDefault(entry.getKey(),List.of()).stream().mapToLong(r->r.packageCount()==null?0:r.packageCount()).sum()!=entry.getValue()) + reasons.add("实托还有未完整选择或重复计入的真实可用库存来源"); + } + } + var bases=new TreeMap();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false)); + String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets)); + var versions=new TreeMap();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version)))); + var sources=new ArrayList(); + for(var entry:rows.entrySet()) { + var row=entry.getValue();var selected=selection(row,versions.get(entry.getKey()));String type=selected.sourceType()==SourceType.RELABEL?"RELABEL_ORDER":selected.sourceType().name(); + String no=selected.sourceType()==SourceType.RELABEL?selected.relabelNo():selected.sourceType()==SourceType.RETURN_RECEIPT?row.returnReceiptNo():row.planNo(); + sources.add(new SelectionGroupSource(selected,entry.getKey(),row.customerId(),row.customerName(),type,no,row.packageCount()==null?0:row.packageCount())); + } + var physical=new ArrayList(); + var physicalRows=rows.values().stream().collect(Collectors.groupingBy(r->r.warehousePalletId()==null?"PLAN:"+r.planPalletId():"PALLET:"+r.warehousePalletId(),TreeMap::new,Collectors.toList())); + for(var group:physicalRows.values()) { + var first=group.get(0);var destinations=group.stream().map(r->com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode())).distinct().count(); + if(destinations!=1)reasons.add("同一实托目的地不一致,请核对实际组托"); + physical.add(new SelectionGroupPallet(first.warehousePalletId(),first.palletNo(),group.stream().map(r->wholeSourceKey(selection(r,null))).sorted().toList(),group.stream().mapToLong(r->r.packageCount()==null?0:r.packageCount()).sum())); + } + String groupKey="SG-"+wholeHashes.source(Arrays.asList(rows.keySet(),pallets)); + return new SourceGroup(new SelectionGroup(groupKey,version,List.copyOf(sources),List.copyOf(physical),reasons.isEmpty(),List.copyOf(reasons)),Collections.unmodifiableMap(rows),Collections.unmodifiableMap(versions)); + } + + /** 整车一次选择的真实客户来源,未持久化客户子排车。 */ + public record WholeSource(DispatchPalletCandidate candidate, + com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { } + + /** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */ + public List wholeCandidates(String operator, + com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) { + validateSourceStage(query.sourceStage()); + if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream() + .noneMatch(d -> query.draftToken().equals(d.getDraftToken()))) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源"); + var result=new java.util.ArrayList(); + if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query)); + if(query.planIds()==null || query.planIds().isEmpty()) { + if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query)); + if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) { + var rows=wholeSources.availableRelabel(query); + if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); + result.addAll(measuredRelabelCandidates(rows)); + result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo())); + } + } + if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); + return result.stream().filter(row -> query.destinationWarehouseCode()==null + || query.destinationWarehouseCode().equals(row.fbaWarehouseCode())) + .sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),"")) + .thenComparing(row -> Objects.toString(row.orderNo(),"")) + .thenComparing(row -> Objects.toString(row.palletNo(),""))).toList(); + } + + /** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public List lockWholeSources(String warehouseId, + List selections) { + return resolveWholeSources(warehouseId,selections,true,500,List.of()); + } + + /** 加货先并入本车原实托范围,避免持有新托库存后才反向取得旧托占用锁。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public List lockAdditionalSources(String warehouseId,List selections,List current) { + return resolveWholeSources(warehouseId,selections,true,500,current); + } + + /** 草稿与正式建单都按完整实托来源组锁定,不能拆开同托申请。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public List lockDraftSources(String warehouseId,List selections) { + return resolveWholeSources(warehouseId,selections,true,1000,List.of()); + } + + /** 先锁全部申请/来源订单,再按物理托盘顺序锁来源根和库存,不能按分组交错。 */ + private List resolveWholeSources(String warehouseId,List selections,boolean completeRelabel,int limit,List currentItems) { + if (selections == null || selections.isEmpty()) return List.of(); + if (selections.size() > limit) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"选择来源超过完整核验上限"); + var selectedKeys=selections.stream().map(DispatchService::wholeSourceKey).collect(Collectors.toCollection(TreeSet::new)); + if(selectedKeys.size()!=selections.size())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"同一来源不能重复选择"); + var scopePallets=new TreeSet();var scopeApplications=new TreeSet(); + currentItems.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(scopePallets::add); + if(!scopePallets.isEmpty())relabelSources.snapshot(warehouseId,List.copyOf(scopePallets)).applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).forEach(scopeApplications::add); + currentItems.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).forEach(scopeApplications::add); + for(var selection:selections) { + var group=sourceGroup(readSelection(warehouseId,selection),false); + if(!group.view().complete())throw new ResponseStatusException(HttpStatus.CONFLICT,"完整实托来源尚未核齐:"+String.join(";",group.view().blockReasons())); + if(!selectedKeys.containsAll(group.rows().keySet()))throw new ResponseStatusException(HttpStatus.CONFLICT,"须一次完整选择同实托全部来源及整换标申请全部应排托盘"); + group.view().pallets().stream().map(SelectionGroupPallet::warehousePalletId).filter(Objects::nonNull).forEach(scopePallets::add); + group.view().sources().stream().map(SelectionGroupSource::selection).filter(s->s.sourceType()==SourceType.RELABEL).map(DispatchCommands.CargoSelection::relabelNo).forEach(scopeApplications::add); + } + if(!scopePallets.isEmpty())lockRelabelApplications(scopeApplications); + var chosen = new java.util.LinkedHashMap(); + var relabelGroups = selections.stream().filter(s -> s != null && s.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.RELABEL) + .collect(Collectors.groupingBy(s -> Objects.toString(s.relabelNo(), ""), java.util.TreeMap::new, Collectors.toList())); + var relabelRows = new java.util.HashMap>(); + for (var group : relabelGroups.entrySet()) { + String customer = wholeSources.relabelOwner(group.getKey()); + var application = customer == null ? null : mutations().lockRelabel(customer, group.getKey()); + if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus()) || !"TRUCK".equals(application.getDeliveryMethod()) + || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"换标申请尚不具备排车资格"); + var complete = java.util.stream.Stream.concat(measuredRelabelCandidates(mutations().relabelCandidates(customer, group.getKey())).stream(), + returnRelabelStock.candidates(customer,null,group.getKey()).stream()).toList(); + var requested = group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection::warehousePalletId).collect(Collectors.toSet()); + var available=complete.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()); + if (requested.contains(null) || requested.size() != group.getValue().size() || complete.isEmpty() || !available.containsAll(requested) + || completeRelabel && !requested.equals(available)) + throw new ResponseStatusException(HttpStatus.CONFLICT,"换标排车须完整选择本申请全部应排托盘"); + relabelRows.put(group.getKey(), complete); + } + for (var selection : selections) { + String key = wholeSourceKey(selection); + if (chosen.containsKey(key)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"同一来源不能重复选择"); + DispatchPalletCandidate row; + if (selection.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.INBOUND_PLAN) { + String customer = requireSingleCustomerScope(wholeSources.planOwners(selection.planPalletId()), "所选来源"); + row = requireWholeCandidate(wholeSources.selectedPlan(customer, selection.planPalletId())); + } else if (selection.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.RETURN_RECEIPT) { + String customer = requireSingleCustomerScope(wholeSources.returnOwners(selection.returnReceiptId()), "所选退回来源"); + row = requireWholeCandidate(wholeSources.selectedReturn(customer, selection.returnReceiptId())); + } else { + row = relabelRows.getOrDefault(selection.relabelNo(), List.of()).stream() + .filter(c -> Objects.equals(c.warehousePalletId(), selection.warehousePalletId())).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.CONFLICT,"换标托盘来源已变化")); + } + if (!Objects.equals(warehouseId, row.overseasWarehouseId()) || row.packageCount() == null || row.packageCount() <= 0) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物必须属于本车起运仓且有真实可排数量"); + if(selection.warehousePalletId()!=null && !Objects.equals(selection.warehousePalletId(),row.warehousePalletId())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选实托身份已变化,请重新核对来源"); + if (row.customerId() == null || !row.customerId().matches("[1-9][0-9]*")) + throw new ResponseStatusException(HttpStatus.CONFLICT,"来源缺少真实客户归属"); + chosen.put(key, new WholeSource(row, selection, key, null)); + } + // 先收齐所有客户/所有实托的来源订单锁,不在拿到库存锁后再回头取另一订单。 + var owners = new java.util.TreeMap(); + for (var source : chosen.values()) { + var row = source.candidate(); + var ids = source.selection().sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.INBOUND_PLAN + ? wholeSources.sourceOrders(row.customerId(),row.warehousePalletId(),row.planPalletId()) + : row.orderId() == null ? List.of() : List.of(row.orderId()); + if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物缺少真实订单组成"); + ids.forEach(id -> owners.put(id,row.customerId())); + } + var allOrders=new TreeMap();for(var ref:wholeSources.physicalOrders(warehouseId,List.copyOf(scopePallets)))allOrders.put(ref.orderId(),ref.customerId()); + allOrders.putAll(executionOrders(warehouseId,currentItems,scopePallets)); + allOrders.putAll(owners); + allOrders.forEach((id,customer) -> { if ((owners.containsKey(id)?wholeSources.lockOrder(customer,id):wholeSources.lockOrderIdentity(customer,id)) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"来源订单缺失或已暂扣,不能排车"); }); + palletSerial.lock(List.copyOf(scopePallets)); + // 在共同串行点后重读;新申请/订单/实托必须整笔重试,不能临时追加乱序锁。 + var currentOrders=new TreeMap();for(var ref:wholeSources.physicalOrders(warehouseId,List.copyOf(scopePallets)))currentOrders.put(ref.orderId(),ref.customerId());currentOrders.putAll(owners); + currentOrders.putAll(executionOrders(warehouseId,currentItems,scopePallets)); + if(!currentOrders.equals(allOrders))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托真实来源订单组成已变化,请重新读取"); + for(var source:chosen.values()) { + var fresh=readSelection(warehouseId,source.selection());var group=sourceGroup(fresh,!scopePallets.isEmpty()); + if(!group.view().complete() || !selectedKeys.containsAll(group.rows().keySet()) || group.view().pallets().stream().map(SelectionGroupPallet::warehousePalletId).filter(Objects::nonNull).anyMatch(p->!scopePallets.contains(p))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源组成已变化,请重新读取"); + } + for(var no:relabelGroups.keySet()) { + String customer=wholeSources.relabelOwner(no);var application=mutations().lockRelabel(customer,no); + relabelBindingGuard.requireBound(application);relabelMeasurements.requireMeasured(application); + } + var availableBuckets=new HashMap(); + for(var source:chosen.values())if(source.selection().sourceType()==SourceType.INBOUND_PLAN && source.candidate().warehousePalletId()!=null) { + String bucket=source.candidate().customerId()+":"+source.candidate().warehousePalletId(); + if(availableBuckets.put(bucket,source.candidate().planPalletId())!=null || chosen.values().stream().anyMatch(s->s.selection().sourceType()==SourceType.RETURN_RECEIPT && bucket.equals(s.candidate().customerId()+":"+s.candidate().warehousePalletId()))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"同一可用库存组成不能按多个来源别名重复计入"); + } + var result = new java.util.ArrayList(); + for (var source : chosen.values().stream().sorted(java.util.Comparator.comparingLong(s -> + s.candidate().warehousePalletId() == null ? Long.MAX_VALUE : s.candidate().warehousePalletId()) + .thenComparingLong(s -> s.candidate().planPalletId() == null ? 0L : s.candidate().planPalletId())).toList()) { + var row = source.candidate(); + if (row.planPalletId() != null && wholeSources.lockPlan(row.customerId(),row.planPalletId()) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"计划托盘来源已变化"); + if (row.returnReceiptId() != null && wholeSources.lockReturn(row.customerId(),row.returnReceiptId()) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"退回实收来源已变化"); + wholeSources.stock(row.customerId(),row.warehousePalletId(),true); + } + for(var source:chosen.values()) { + var row=readSelection(warehouseId,source.selection());var group=sourceGroup(row,!scopePallets.isEmpty());String version=group.versions().get(source.sourceKey()); + if(!group.view().complete() || !selectedKeys.containsAll(group.rows().keySet()))throw new ResponseStatusException(HttpStatus.CONFLICT,"锁内完整实托组成未核齐"); + if (!Objects.equals(version,source.selection().sourceVersion())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选来源数量、组成或版本已变化,请重新核对"); + result.add(new WholeSource(row,source.selection(),source.sourceKey(),version)); + } + return List.copyOf(result); + } + + /** 外层草稿整批已锁全集时只重入子集,不让嵌套正式创建替换或追加申请锁。 */ + private void lockRelabelApplications(Set requested) { + Object existing=org.springframework.transaction.support.TransactionSynchronizationManager.getResource(relabelLockScope); + if(existing instanceof Set prior) { + if(!prior.containsAll(requested))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托申请锁范围已变化,请重新读取完整组成"); + return; + } + relabelSources.lockApplications(List.copyOf(requested));var locked=Set.copyOf(requested); + org.springframework.transaction.support.TransactionSynchronizationManager.bindResource(relabelLockScope,locked); + org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization() { + /** 新事务不能借用被挂起事务的申请锁证明。 */ + @Override public void suspend(){org.springframework.transaction.support.TransactionSynchronizationManager.unbindResourceIfPossible(relabelLockScope);} + /** 恢复原事务时恢复其同一锁集合。 */ + @Override public void resume(){org.springframework.transaction.support.TransactionSynchronizationManager.bindResource(relabelLockScope,locked);} + /** 事务结束后清除本次锁集合。 */ + @Override public void afterCompletion(int status){org.springframework.transaction.support.TransactionSynchronizationManager.unbindResourceIfPossible(relabelLockScope);} + }); + } + + /** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List items) { + var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new)); + var initial=relabelSources.snapshot(warehouse,List.copyOf(pallets));var applications=new TreeSet(); + initial.applications().forEach(a->applications.add(a.relabelNo())); + items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).forEach(applications::add); + var codes=items.stream().map(DispatchItem::getPalletNo).filter(Objects::nonNull).distinct().toList(); + var nativeBefore=wholeSources.nativeApplications(warehouse,codes);nativeBefore.forEach(a->applications.add(a.relabelNo())); + lockRelabelApplications(applications); + var orders=executionOrders(warehouse,items,pallets); + orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");}); + palletSerial.lock(List.copyOf(pallets)); + var current=relabelSources.lockedSnapshot(warehouse,List.copyOf(pallets)); + var selectedApplications=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet()); + if(wholeSources.nativeApplications(warehouse,codes).stream().anyMatch(a->!selectedApplications.contains(a.relabelNo())))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托出现未完整选入本车的原生退货换标来源"); + if(!orders.equals(executionOrders(warehouse,items,pallets)) || current.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).anyMatch(p->!pallets.contains(p))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"本车实托来源组成已变化,请回平台核对"); + var positionIds=current.positions().stream().map(RelabelDispatchSourcePort.Position::positionId).collect(Collectors.toSet()); + var stockScopes=new TreeMap>(); + for(var position:current.positions())if(position.warehousePalletId()!=null)stockScopes.computeIfAbsent(position.warehousePalletId(),p->new TreeSet<>()).add(position.customerId()); + for(var entry:stockScopes.entrySet())for(var customer:entry.getValue())wholeSources.stock(customer,entry.getKey(),true); + current=relabelSources.lockedSnapshot(warehouse,List.copyOf(pallets)); + if(!positionIds.equals(current.positions().stream().map(RelabelDispatchSourcePort.Position::positionId).collect(Collectors.toSet())) + || !orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托实际库存位置或真实订单组成已变化,请回平台核对"); + if(!items.isEmpty()) { + var picked=wholeSources.pickedItems(items.get(0).getDispatchOrderId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var expected=new HashMap(); + for(var item:items)if(!"RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getWarehousePalletId()!=null) { + long left=item.getPackageCount()-picked.getOrDefault(item.getId(),0L); + if(left<0)throw new ResponseStatusException(HttpStatus.CONFLICT,"普通来源实际拣货超过本来源箱数"); + expected.merge(item.getCustomerId()+":"+item.getWarehousePalletId(),left,Math::addExact); + } + var actual=current.positions().stream().collect(Collectors.groupingBy(p->p.customerId()+":"+p.warehousePalletId(),Collectors.summingLong(RelabelDispatchSourcePort.Position::availablePackages))); + for(var entry:expected.entrySet())if(!Objects.equals(actual.getOrDefault(entry.getKey(),0L),entry.getValue()))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托普通来源剩余箱数已变化,请回平台核对"); + for(var entry:actual.entrySet())if(!Objects.equals(expected.getOrDefault(entry.getKey(),0L),entry.getValue()))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托出现未完整选入本车的可用库存来源"); + } + return current; + } + + /** 原订单与换标履约单分别锁定,各身份保持真实所属客户。 */ + private TreeMap executionOrders(String warehouse,List items,Set pallets) { + var orders=new TreeMap();for(var ref:wholeSources.physicalOrders(warehouse,List.copyOf(pallets)))orders.put(ref.orderId(),ref.customerId()); + for(var item:items) { + if(item.getOrderId()!=null)orders.put(item.getOrderId(),item.getCustomerId()); + if(item.getPlanPalletId()!=null)for(var id:wholeSources.sourceOrders(item.getCustomerId(),item.getWarehousePalletId(),item.getPlanPalletId()))orders.put(id,item.getCustomerId()); + } + return orders; + } + + /** GET候选与写入锁内使用同一摘要字段;不包含客户端显示顺序或锁拥有者。 */ + public String wholeSourceVersion(DispatchPalletCandidate row, String key, boolean lockStock) { + if(lockStock)wholeSources.stock(row.customerId(),row.warehousePalletId(),true); + return sourceGroup(row,false).versions().get(key); + } + + /** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */ + private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) { + var facts = new java.util.TreeMap(); + facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId()); + facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId()); + facts.put("palletId",row.warehousePalletId()); facts.put("packages",row.packageCount()); facts.put("sourceStage",row.sourceStage()); + facts.put("destinationType",row.destinationType()); facts.put("destinationWarehouseId",row.destinationWarehouseId()); + facts.put("destinationWarehouseCode",row.destinationWarehouseCode()); facts.put("fbaWarehouseCode",row.fbaWarehouseCode()); + facts.put("deliveryAddress",row.deliveryAddress()); facts.put("postalCode",row.postalCode()); + facts.put("contactName",row.contactName()); facts.put("contactPhone",row.contactPhone()); + facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm()); + facts.put("cartonMarks",row.relabelCartonMarks()); + if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':')))); + var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock); + if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId()))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源"); + facts.put("stock",stock); + facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId())); + facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId())); + return wholeHashes.source(facts); + } + + /** 三种可选来源必须互斥,业务键不使用任意客户参数。 */ + public static String wholeSourceKey(com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection source) { + if (source == null || source.sourceType() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择真实货物来源"); + return switch (source.sourceType()) { + case INBOUND_PLAN -> { if (source.planPalletId() == null || source.returnReceiptId() != null || source.relabelNo() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"计划托盘来源字段不匹配"); yield "PLAN:"+source.planPalletId(); } + case RETURN_RECEIPT -> { if (source.returnReceiptId() == null || source.planPalletId() != null || source.relabelNo() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回来源字段不匹配"); yield "RETURN:"+source.returnReceiptId(); } + case RELABEL -> { if (source.relabelNo() == null || source.relabelNo().isBlank() || source.warehousePalletId() == null + || source.planPalletId() != null || source.returnReceiptId() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"换标来源字段不匹配"); yield "RELABEL:"+source.relabelNo()+":"+source.warehousePalletId(); } + }; + } + + /** 只接受唯一来源快照,不以缺失候选伪造成功。 */ + private static DispatchPalletCandidate requireWholeCandidate(List rows) { + if (rows.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物不再具备排车资格"); + return rows.get(0); + } + + /** 所有明细保存成功后推进各真实客户换标申请,仍处在主单事务中。 */ + public void bindWholeRelabel(List sources, List saved) { + for (var item : saved) if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.reserve(item); + for (var no : sources.stream().filter(s -> s.selection().relabelNo() != null).map(s -> s.selection().relabelNo()).distinct().sorted().toList()) { + String customer = sources.stream().filter(s -> no.equals(s.selection().relabelNo())).findFirst().orElseThrow().candidate().customerId(); + if (mutations().markRelabel(customer,no) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT,"换标申请已被其他排车占用"); + } + } + + /** 本车零扫码取消后恢复真实换标申请及原生退货箱预占,不生成退库流水。 */ + public void releaseWholeRelabel(List items) { + var groups=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())) + .collect(Collectors.groupingBy(DispatchItem::getSourceBusinessNo,java.util.TreeMap::new,Collectors.toList())); + for(var group:groups.values()) { + var first=group.get(0);var application=mutations().lockRelabel(first.getCustomerId(),first.getSourceBusinessNo()); + if(application==null || !"PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus()))throw new ResponseStatusException(HttpStatus.CONFLICT,"换标原排车预占状态已变化"); + } + for(var item:items.stream().filter(i->"RETURN_RELABEL".equals(i.getSourceStage())).sorted(java.util.Comparator.comparing(DispatchItem::getId)).toList())returnRelabelStock.release(item); + for(var group:groups.values()){var first=group.get(0);mutations().restoreRelabel(first.getCustomerId(),first.getSourceBusinessNo());} + } + + @Resource private DispatchDraftMapper drafts; + @Resource private DispatchCandidateMapper candidates; @Resource private RelabelApplicationMapper relabelApplications; @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; @Resource private ReturnRelabelDispatchStock returnRelabelStock; @Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements; - + private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() { + return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository(candidates,relabelApplications); + } + private boolean planIdsForReturns(String stage) { return stage == null || stage.isBlank() || "RECEIVED".equals(stage); } private List measuredRelabelCandidates(List rows) { var cache = new java.util.HashMap>(); for (var row : rows) { @@ -97,918 +477,12 @@ public class DispatchService { } return rows; } - - public String resolveCustomerScopeForPlans(List planIds) { - return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), - "Selected inbound plans"); - } - - public String resolveCustomerScopeForPallets(List planPalletIds) { - return requireSingleCustomerScope( - candidates.findCustomerScopesByPlanPalletIds(normalizeIds(planPalletIds, "Plan pallet IDs")), - "Selected pallets"); - } - - public String resolveCustomerScopeForRelabel(String relabelNo) { - String normalized = trimToNull(relabelNo); - RelabelApplication application = normalized == null ? null : relabelApplications.findAnyByRelabelNo(normalized); - if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "换标单不存在,请检查换标单号"); - if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); - } - if (!"TRUCK".equals(application.getDeliveryMethod())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "只有平台卡车派送的换标单可以排车"); - } - return application.getCustomerId(); - } - - public List candidates(String customerId, List planIds, String orderNo, - String fbaWarehouseCode, String sourceStage, String draftToken) { - validateSourceStage(sourceStage); - if (!StringUtils.hasText(customerId)) return List.of(); - List scopedPlanIds = normalizeIds(planIds, "Plan IDs"); - return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode), - trimToNull(sourceStage), trimToNull(draftToken)); - } - public List candidates(String customerId, List planIds, String orderNo, - String fbaWarehouseCode, String sourceStage) { - return candidates(customerId, planIds, orderNo, fbaWarehouseCode, sourceStage, null); - } - - public List platformCandidates(String orderNo, String fbaWarehouseCode, - String sourceStage, String draftToken) { - validateSourceStage(sourceStage); - List result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), - trimToNull(sourceStage), trimToNull(draftToken)); - if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) { - List relabel = java.util.stream.Stream.concat( - measuredRelabelCandidates(candidates.relabelAvailable(null, trimToNull(orderNo), null)).stream(), - returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList(); - if (relabel != null && !relabel.isEmpty()) { - result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item -> - !StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "") - .toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList(); - } - } - if (planIdsForReturns(sourceStage)) result = java.util.stream.Stream.concat(result.stream(), - returnCandidates.available(null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(draftToken)).stream()).toList(); - return result; - } - private boolean planIdsForReturns(String sourceStage) { return sourceStage == null || sourceStage.isBlank() || "RECEIVED".equals(sourceStage); } - public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) { - return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); - } - - public List activeDrafts(String operatorId) { - return drafts.findActiveByOperator(trimToNull(operatorId)).stream() - .map(item -> new DispatchDraftResponse(item.getDraftToken(), item.getPlanIdsJson(), item.getContainersJson(), containers(item.getDraftToken()))).toList(); - } - - private List containers(String token) { - return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), - locks.findActiveByDraftContainer(token, c.getId()).stream() - .map(x -> x.getReturnReceiptId() == null ? String.valueOf(x.getPlanPalletId()) : "RETURN:" + x.getReturnReceiptId()).toList())).toList(); - } - - @Transactional - public DispatchContainerResponse createDraftContainer(String operatorId, DispatchRequests.DraftContainer request) { - if (!Set.of(15, 30).contains(request.capacity())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托"); - } - DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); - int next = draftContainers.findByToken(request.draftToken()).stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0) + 1; - DispatchDraftContainer c = new DispatchDraftContainer(); c.setId(IdWorker.getId()); c.setDraftId(draft.getId()); c.setDraftToken(request.draftToken()); c.setSequenceNo(next); c.setCapacity(request.capacity()); c.setCreatedAt(LocalDateTime.now()); c.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(c); - return new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), List.of()); - } - - private DispatchDraft ensureDraft(String operatorId, String token) { - DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId)); - if (draft != null) return draft; - LocalDateTime now = LocalDateTime.now(); draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(token); draft.setOperatorId(trimToNull(operatorId)); draft.setStatus("ACTIVE"); draft.setCreatedAt(now); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); drafts.insert(draft); return draft; - } - - @Transactional - public void saveDraftSnapshot(String operatorId, DispatchRequests.DraftSnapshot request) { - String customerId = resolveCustomerScopeForPlans(request.planIds()); - LocalDateTime now = LocalDateTime.now(); - DispatchDraft draft = drafts.lockActive(request.draftToken(), trimToNull(operatorId)); - if (draft == null) { - draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(request.draftToken()); - draft.setOperatorId(trimToNull(operatorId)); draft.setCustomerId(customerId); draft.setStatus("ACTIVE"); - draft.setCreatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); - } - draft.setPlanIdsJson(request.planIds().stream().map(String::valueOf).collect(Collectors.joining(","))); - draft.setContainersJson(request.containersJson()); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); - drafts.insertOrUpdate(draft); - } - - @Transactional - public void lockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); - DispatchDraftContainer boundContainer = request.draftContainerId() == null ? null : draftContainers.selectById(request.draftContainerId()); - if (boundContainer == null && request.draftContainerId() == null) { - var existing = draftContainers.findByToken(request.draftToken()); - if (existing.size() > 1) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "存在多个装车分组,请明确选择目标分组"); - if (existing.size() == 1) boundContainer = existing.get(0); - else { - boundContainer = new DispatchDraftContainer(); boundContainer.setId(IdWorker.getId()); boundContainer.setDraftId(draft.getId()); boundContainer.setDraftToken(request.draftToken()); boundContainer.setSequenceNo(1); boundContainer.setCapacity(30); boundContainer.setCreatedAt(LocalDateTime.now()); boundContainer.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(boundContainer); - } - } - if (boundContainer == null || !request.draftToken().equals(boundContainer.getDraftToken()) || !Objects.equals(boundContainer.getDraftId(), draft.getId())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不存在"); - DispatchPalletCandidate candidate = requireDraftSource(customerId, request); - LocalDateTime now = LocalDateTime.now(); - DispatchPalletLock active = activeLock(customerId, candidate); - if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) { - releaseLock(active, now); - active = null; - } - if (active != null) { - if (request.draftToken().equals(active.getDraftToken()) - && Objects.equals(trimToNull(operatorId), active.getOperatorId())) { - if (!Objects.equals(boundContainer.getId(), active.getDraftContainerId())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "托盘已锁定到其他装车分组,请先移除后重新选择"); - return; - } - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another dispatch draft: " + candidate.palletNo()); - } - // Older releases did not persist active_key=NULL; clear that inactive unique-key residue before relocking. - locks.clearInactiveActiveKey(customerId, candidate.sourcePalletKey()); - 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.setReturnReceiptId(candidate.returnReceiptId()); - lock.setWarehousePalletId(candidate.warehousePalletId()); - lock.setDraftToken(request.draftToken()); - lock.setDraftContainerId(boundContainer.getId()); - lock.setOperatorId(trimToNull(operatorId)); - lock.setStatus("ACTIVE"); - lock.setLockedAt(now); - lock.setExpiresAt(now.plusMinutes(30)); - lock.setCreatedAt(now); - lock.setUpdatedAt(now); - try { - locks.insert(lock); - } catch (DuplicateKeyException exception) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another dispatch draft: " + candidate.palletNo(), exception); - } - } - - @Transactional - public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchPalletCandidate candidate = requireDraftSource(customerId, request); - DispatchPalletLock active = activeLock(customerId, candidate); - if (active == null) return; - requireDraftOwner(active, operatorId, request.draftToken()); - if (active.getDispatchOrderId() != null) { - DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId()); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - requirePalletEditingAllowed(dispatch); - DispatchItem item = request.returnReceiptId() == null ? items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()) - : items.selectOne(new LambdaQueryWrapper().eq(DispatchItem::getCustomerId, customerId) - .eq(DispatchItem::getDispatchOrderId, dispatch.getId()).eq(DispatchItem::getReturnReceiptId, request.returnReceiptId()).last("FOR UPDATE")); - if (item != null) removeLockedItem(dispatch, item, List.of(active)); - else releaseLock(active, LocalDateTime.now()); - return; - } - releaseLock(active, LocalDateTime.now()); - } - - @Transactional - public void releaseDraft(String operatorId, String draftToken) { - List activeLocks = locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getDraftToken, draftToken) - .eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId)) - .eq(DispatchPalletLock::getStatus, "ACTIVE") - .isNull(DispatchPalletLock::getDispatchOrderId)); - LocalDateTime now = LocalDateTime.now(); - activeLocks.forEach(lock -> releaseLock(lock, now)); - draftContainers.delete(new LambdaQueryWrapper().eq(DispatchDraftContainer::getDraftToken, draftToken)); - DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); - if (draft != null) { draft.setStatus("RELEASED"); draft.setReleasedAt(now); draft.setUpdatedAt(now); drafts.updateById(draft); } - } - - @Transactional - public void deleteDraftContainer(String operatorId, String token, Long containerId) { - if (drafts.lockActive(token, trimToNull(operatorId)) == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿不存在或已失效"); - DispatchDraftContainer c = draftContainers.selectById(containerId); - if (c == null || !token.equals(c.getDraftToken())) return; - List bound = locks.selectList(new LambdaQueryWrapper().eq(DispatchPalletLock::getDraftContainerId, containerId).eq(DispatchPalletLock::getStatus, "ACTIVE")); - bound.forEach(lock -> { requireDraftOwner(lock, operatorId, token); releaseLock(lock, LocalDateTime.now()); }); - draftContainers.deleteById(containerId); - } - - @Transactional - public List preArrangeDraft( - String operatorId, DispatchRequests.PreArrangeDraft request) { - String token = trimToNull(request.draftToken()); - if (token == null || request.groups() == null || request.groups().isEmpty() || request.groups().size() > 100) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择需要预排的装车分组"); - DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId)); - if (draft == null || draft.getExpiresAt() != null && !draft.getExpiresAt().isAfter(LocalDateTime.now())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "排车草稿已失效或不属于当前操作人"); - var containers = draftContainers.findByToken(token); - var groups = new java.util.LinkedHashMap(); - Set allSources = new HashSet<>(); - for (var group : request.groups()) { - if (group == null || group.draftContainerId() == null || groups.putIfAbsent(group.draftContainerId(), group) != null) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车分组不能重复"); - Set selected = requestedGroupSources(group); - if (selected.isEmpty() || !selected.stream().allMatch(allSources::add)) - throw new ResponseStatusException(HttpStatus.CONFLICT, "每个来源只能加入一个非空装车分组"); - } - if (allSources.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); - var prepared = new java.util.ArrayList(); - Set physicalPallets = new HashSet<>(); - Set dispatchIds = new HashSet<>(); - Set actualGroups = new HashSet<>(); - for (var container : containers.stream().sorted(java.util.Comparator.comparing(DispatchDraftContainer::getId)).toList()) { - if (!Objects.equals(container.getDraftId(), draft.getId())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不属于当前草稿"); - List bound = locks.lockActiveByDraftContainer(token, container.getId()); - if (bound.isEmpty()) continue; - actualGroups.add(container.getId()); - var group = groups.get(container.getId()); - if (group == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿分组已变化,请重新加载"); - if (container.getCapacity() == null || !Set.of(15, 30).contains(container.getCapacity())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托"); - if (bound.size() > container.getCapacity()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组超过车型托位容量"); - Set actual = new HashSet<>(); - Set customers = new HashSet<>(); - Set groupDispatchIds = new HashSet<>(); - for (var lock : bound) { - requireDraftOwner(lock, operatorId, token); - actual.add(lock.getReturnReceiptId() == null ? "PLAN:" + lock.getPlanPalletId() : "RETURN:" + lock.getReturnReceiptId()); - customers.add(lock.getCustomerId()); - if (lock.getSourcePalletKey() == null || !physicalPallets.add(lock.getSourcePalletKey())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复排车"); - if (lock.getDispatchOrderId() != null) groupDispatchIds.add(lock.getDispatchOrderId()); - } - if (!actual.equals(requestedGroupSources(group)) || actual.size() != bound.size()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "托盘所属装车分组已变化,请重新加载"); - String customer = resolveCustomerScopeForSources(group.planPalletIds(), group.returnReceiptIds()); - if (!customers.equals(Set.of(customer))) throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组客户归属不一致"); - if (groupDispatchIds.size() > 1 || !groupDispatchIds.stream().allMatch(dispatchIds::add)) - throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿与排车分组不一致,请核对原排车单"); - prepared.add(new PreparedDispatchGroup(container.getId(), customer, group)); - } - if (!actualGroups.equals(groups.keySet())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不存在、为空或已失效"); - var results = new java.util.ArrayList(); - for (var group : prepared) { - var command = new DispatchRequests.PreArrange(group.request().planPalletIds(), null, null, null, - token, null, group.request().returnReceiptIds(),group.request().batchAllocations()); - var response = preArrangeContainer(group.customerId(), operatorId, command, group.containerId(), true); - results.add(new com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult(group.containerId(), response)); - } - return List.copyOf(results); - } - - private Set requestedGroupSources(DispatchRequests.DraftGroup group) { - Set result = new HashSet<>(); - for (Long id : group.planPalletIds()) if (id == null || !result.add("PLAN:" + id)) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不能重复或为空"); - for (Long id : group.returnReceiptIds()) if (id == null || !result.add("RETURN:" + id)) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "退回来源不能重复或为空"); - return result; - } - - private record PreparedDispatchGroup(Long containerId, String customerId, DispatchRequests.DraftGroup request) { } - - @Transactional - public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { - return preArrangeContainer(customerId, operatorId, request, null, false); - } - - private DispatchResponse preArrangeContainer(String customerId, String operatorId, - DispatchRequests.PreArrange request, Long expectedContainerId, boolean preserveExactReplay) { - if(StringUtils.hasText(request.fbaAppointmentNo()) && request.batchAllocations().isEmpty()) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"预约关联必须提供不可变批次键及逐票实物分配,不能仅填写预约号"); - if (StringUtils.hasText(request.relabelNo())) { - if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用"); - return preArrangeRelabel(customerId, operatorId, request); - } - if (!StringUtils.hasText(customerId)) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "No customer business data is available for dispatch"); - } - List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); - List receiptIds = request.returnReceiptIds().stream().distinct().sorted().toList(); - if (palletIds.size() + receiptIds.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); - if ((palletIds.isEmpty() && receiptIds.isEmpty()) || palletIds.size() != request.planPalletIds().size() - || receiptIds.size() != request.returnReceiptIds().size()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); - } - List selected = new java.util.ArrayList<>(); - if (!palletIds.isEmpty()) selected.addAll(candidates.lockSelected(customerId, palletIds)); - if (!receiptIds.isEmpty()) selected.addAll(returnCandidates.lockSelected(customerId, receiptIds)); - if (selected.size() != palletIds.size() + receiptIds.size()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); - } - if (selected.stream().map(DispatchPalletCandidate::sourcePalletKey).distinct().count() != selected.size()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复选择,请先拆托核对来源"); - if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) { - boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType())); - throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba - ? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车"); - } - assertNoDetainedOrders(customerId, selected); - boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .anyMatch(value -> value == null || value.isBlank()); - if (missingWarehouse) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "所选托盘中存在尚未关联海外仓的托盘"); - } - Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .map(String::trim).collect(Collectors.toSet()); - if (warehouseIds.size() != 1) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘"); - } - String draftToken = trimToNull(request.draftToken()); - Set selectedContainers = new HashSet<>(); - for (DispatchPalletCandidate candidate : selected) { - DispatchPalletLock active = activeLock(customerId, candidate); - if (draftToken != null) { - if (active == null) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet draft lock has expired: " + candidate.palletNo()); - } - requireDraftOwner(active, operatorId, draftToken); - selectedContainers.add(active.getDraftContainerId()); - } else if (active != null) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another active dispatch: " + candidate.palletNo()); - } - } - - if (draftToken != null && (selectedContainers.size() != 1 || selectedContainers.contains(null) - || expectedContainerId != null && !selectedContainers.contains(expectedContainerId))) - throw new ResponseStatusException(HttpStatus.CONFLICT, "多个装车分组必须按分组批量预排,不能合并提交"); - - LocalDateTime now = LocalDateTime.now(); - Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraftContainer(draftToken, selectedContainers.iterator().next()); - DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId); - boolean refreshing = dispatch != null; - if (preserveExactReplay && refreshing) { - var existing = items.findByDispatchOrder(customerId, dispatch.getId()); - if (existing.size() == selected.size() && existing.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()) - .equals(selected.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()))) - { appointmentAllocations.requireReplay(customerId,dispatch.getId(),request.batchAllocations()); return response(dispatch, existing); } - } - if (refreshing) { - requirePalletEditingAllowed(dispatch); - } else { - dispatch = new DispatchOrder(); - dispatch.setId(IdWorker.getId()); - dispatch.setCustomerId(customerId); - dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId())); - dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); - dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setStatus(PENDING_DEPARTURE); - dispatch.setPickedPackageCount(0); - dispatch.setShippedPackageCount(0); - dispatch.setCreatedBy(trimToNull(operatorId)); - dispatch.setCreatedAt(now); - } - dispatch.setRoute(candidateRoute(selected)); - dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); - dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); - dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); - dispatch.setItemCount(selected.size()); - dispatch.setPalletCount(selected.size()); - dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount) - .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - dispatch.setUpdatedAt(now); - if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch); - - List previousItems = items.findByDispatchOrder(customerId, dispatch.getId()); - Set selectedPalletIds = selected.stream().map(DispatchService::selectionKey) - .collect(Collectors.toSet()); - if (refreshing) { - List previousLocks = locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, customerId) - .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); - previousItems.stream().filter(item -> !selectedPalletIds.contains(selectionKey(item))) - .forEach(item -> { - previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())) - .forEach(lock -> releaseLock(lock, now)); - items.deleteById(item.getId()); - }); - } - Set existingPalletIds = previousItems.stream() - .map(DispatchService::selectionKey).filter(selectedPalletIds::contains).collect(Collectors.toSet()); - - for (DispatchPalletCandidate candidate : selected) { - DispatchPalletLock lock = activeLock(customerId, candidate); - if (draftToken != null) { - requireDraftOwner(lock, operatorId, draftToken); - lock.setDispatchOrderId(dispatch.getId()); - lock.setExpiresAt(null); - lock.setUpdatedAt(now); - locks.updateById(lock); - } else { - 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.setReturnReceiptId(candidate.returnReceiptId()); - lock.setWarehousePalletId(candidate.warehousePalletId()); - lock.setDispatchOrderId(dispatch.getId()); - lock.setOperatorId(trimToNull(operatorId)); - 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); - } - } - if (existingPalletIds.contains(selectionKey(candidate))) continue; - DispatchItem item = new DispatchItem(); - item.setId(IdWorker.getId()); - item.setDispatchOrderId(dispatch.getId()); - item.setCustomerId(customerId); - item.setPlanId(candidate.planId()); - item.setPlanPalletId(candidate.planPalletId()); - item.setReturnReceiptId(candidate.returnReceiptId()); - 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.setDestinationType(candidate.destinationType()); - item.setDeliveryAddress(candidate.deliveryAddress()); - item.setPostalCode(candidate.postalCode()); - item.setContactName(candidate.contactName()); - item.setContactPhone(candidate.contactPhone()); - item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); - item.setGrossWeightKg(candidate.grossWeightKg()); - item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); - item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); - item.setSourceStage(candidate.sourceStage()); - item.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks())); - item.setCreatedAt(now); - items.insert(item); - } - if (!refreshing) { - DispatchPdaTask pdaTask = new DispatchPdaTask(); - pdaTask.setId(IdWorker.getId()); - pdaTask.setCustomerId(customerId); - pdaTask.setDispatchOrderId(dispatch.getId()); - pdaTask.setTaskNo("DPDA" + pdaTask.getId()); - pdaTask.setStatus("PDA_PENDING"); - pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId()); - pdaTask.setPushedAt(now); - pdaTask.setCreatedAt(now); - pdaTask.setUpdatedAt(now); - pdaTasks.insert(pdaTask); - } - if (draftToken != null) { - DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); - if (draft != null) { - draft.setExpiresAt(null); - draft.setUpdatedAt(now); - drafts.updateById(draft); - } - } - // Exact grouped replay must return the same persisted timestamp precision - // on the initial call as on later reads from DATETIME(3). - DispatchOrder responseOrder = preserveExactReplay - ? Objects.requireNonNull(orders.lockById(customerId, dispatch.getId()), "Saved dispatch is missing") - : dispatch; - var assignedItems=items.findByDispatchOrder(customerId, dispatch.getId()); - appointmentAllocations.bind(customerId,dispatch.getId(),assignedItems.stream().map(i->new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList(),request.batchAllocations()); - return response(responseOrder, assignedItems); - } - - /** 在申请锁内校验全托并原子建立整单排车。 */ - @Transactional - private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { - RelabelApplication application = mutations().lockRelabel(customerId, request.relabelNo()); - if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); - } - if (!"TRUCK".equals(application.getDeliveryMethod()) - || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态"); - relabelBindingGuard.requireBound(application); - relabelMeasurements.requireMeasured(application); - List selected = java.util.stream.Stream.concat( - measuredRelabelCandidates(mutations().relabelCandidates(customerId, request.relabelNo())).stream(), - returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList(); - // 整单履约状态只能由完整托盘集合推进,普通换标与退货换标遵守同一门禁。 - Set requested = new HashSet<>(request.planPalletIds()); - if (requested.contains(null) || requested.size() != request.planPalletIds().size()) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标托盘不得为空或重复选择"); - if (selected.isEmpty() || !requested.equals(selected.stream() - .map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()))) - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标排车须一次选择本申请全部已绑定托盘,请刷新候选后完整选择"); - selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); - if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); - String warehouse = selected.get(0).overseasWarehouseId(); - if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓"); - LocalDateTime now = LocalDateTime.now(); - DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); - dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setRoute(candidateRoute(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); mutations().createOrder(dispatch); - for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); 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.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); mutations().createItem(item); } - DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); mutations().createTask(task); - for (DispatchItem item : mutations().items(dispatch)) { - if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId()) - && Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) { - item.setSourceStage("RETURN_RELABEL"); mutations().saveItem(item); returnRelabelStock.reserve(item); - } - } - if (mutations().markRelabel(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); - return response(dispatch, mutations().items(dispatch)); - } - - private void assertNoDetainedOrders(String customerId, List selected) { - List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) - .filter(Objects::nonNull).distinct().sorted().toList(); - if (orderIds.isEmpty()) return; - List scoped = tailOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) - .in(LastlegOrder::getId, orderIds) - .orderByAsc(LastlegOrder::getId) - .last("FOR UPDATE")); - List detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained())) - .map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList(); - if (!detainedNos.isEmpty()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos)); - } - } - - public PageResult page(String customerId, DispatchRequests.Query query) { - if (!StringUtils.hasText(customerId)) { - return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); - } - return page(customerId, query, false); - } - - public PageResult platformPage(DispatchRequests.Query query) { - return page(null, query, true); - } - - private PageResult page(String customerId, DispatchRequests.Query query, - boolean acrossCustomers) { - Page page = orders.selectPage(new Page<>(query.page(), query.size()), - new LambdaQueryWrapper() - .eq(!acrossCustomers, 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()) - .in(query.stage() != null, DispatchOrder::getStatus, - query.stage() == null ? List.of() : query.stage().statuses()) - .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo, - query.fbaAppointmentNo()) - .orderByDesc(DispatchOrder::getCreatedAt)); - var workflows = acrossCustomers ? fleetWorkflow.read(page.getRecords().stream() - .map(DispatchOrder::getDispatchNo).toList()) : java.util.Map.of(); - var appointmentReferences=appointmentAllocations.references(page.getRecords().stream().map(DispatchOrder::getId).toList()); - List records = page.getRecords().stream() - .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()), - List.of(), workflows.get(order.getDispatchNo()),appointmentReferences.getOrDefault(order.getId(),List.of()))).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(dispatch.getCustomerId(), dispatch.getId()), - bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); - } - - public DispatchResponse platformDetail(String dispatchNo) { - DispatchOrder dispatch = orders.findGlobalByDispatchNo(trimToNull(dispatchNo)); - if (dispatch == null) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - } - return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()), - bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); - } - - /** 仅编辑未执行且无有效出库或车次占用的排车。 */ - @Transactional - public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) { - DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - requirePalletEditingAllowed(dispatch); - String customerId = dispatch.getCustomerId(); - List assignedItems = mutations().items(dispatch); - dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); - dispatch.setDriverName(trimToNull(request.driverName())); - dispatch.setDriverPhone(trimToNull(request.driverPhone())); - dispatch.setCarrierName(trimToNull(request.carrierName())); - dispatch.setDepartAt(request.departAt()); - dispatch.setRoute(itemRoute(assignedItems)); - dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); - dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); - dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); - dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); - dispatch.setUpdatedAt(LocalDateTime.now()); - mutations().saveOrder(dispatch); - return response(dispatch, assignedItems); - } - - /** 移除普通来源托盘,整单换标只允许整单取消重排。 */ - @Transactional - public DispatchResponse removeItem(String dispatchNo, Long itemId) { - DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - requirePalletEditingAllowed(dispatch); - DispatchItem item = mutations().lockItem(dispatch, itemId); - if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found"); - if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标按整单排车,请取消整张排车单后重新排车"); - - List activeLocks = mutations().activeLocks(dispatch); - List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId()) - || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId()))) - .toList(); - return removeLockedItem(dispatch, item, matchingLocks); - } - - /** 同事务释放当前托盘锁、预约分配并重算剩余排车货量。 */ - private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item, - List matchingLocks) { - LocalDateTime now = LocalDateTime.now(); - matchingLocks.forEach(lock -> releaseLock(lock, now)); - mutations().removeItem(item.getId()); - appointmentAllocations.removeItem(dispatch.getId(),item.getId()); - - List remaining = mutations().items(dispatch); - dispatch.setItemCount(remaining.size()); - dispatch.setPalletCount(remaining.size()); - dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) - .mapToInt(Integer::intValue).sum()); - dispatch.setRoute(itemRoute(remaining)); - dispatch.setUpdatedAt(now); - mutations().saveOrder(dispatch); - return response(dispatch, remaining); - } - - /** 取消历史不占活动出库,已拣货或车次绑定仍禁止修改。 */ - private void requirePalletEditingAllowed(DispatchOrder dispatch) { - fleetBindings.requireUnbound(dispatch.getId()); - if (!PENDING_DEPARTURE.equals(dispatch.getStatus()) - || Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0 - || Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets"); - } - var outbound = mutations().outbound(dispatch, false); - if (outbound != null && !CANCELLED.equals(outbound.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘"); - } - } - - /** 只取消原未执行排车,取消出库历史保留且重复取消无副作用。 */ - @Transactional - public void cancel(String dispatchNo) { - DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - fleetBindings.requireUnbound(dispatch.getId()); - if (CANCELLED.equals(dispatch.getStatus())) return; - if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); - } - String customerId = dispatch.getCustomerId(); - if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0 - || Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) - throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱"); - var outbound = mutations().outbound(dispatch, true); - if (outbound != null && !CANCELLED.equals(outbound.getStatus())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车"); - LocalDateTime now = LocalDateTime.now(); - dispatch.setStatus(CANCELLED); - workChanges.requirementsChanged(); - dispatch.setUpdatedAt(now); - mutations().saveOrder(dispatch); - for (DispatchItem item : mutations().items(dispatch)) { - // 预约分配的有效占用按排车 CANCELLED 状态排除,原分配保留作历史证据。 - if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item); - } - mutations().items(dispatch).stream() - .filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) - .map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct() - .forEach(relabelNo -> mutations().restoreRelabel(customerId, relabelNo)); - List activeLocks = mutations().activeLocks(dispatch); - for (DispatchPalletLock lock : activeLocks) { - lock.setStatus("RELEASED"); - lock.setActiveKey(null); - lock.setReleasedAt(now); - lock.setUpdatedAt(now); - mutations().saveLock(lock); - } - } - - private DispatchOrder find(String customerId, String dispatchNo) { - if (!StringUtils.hasText(customerId)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - } - 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) { - return response(order, itemList, List.of()); - } - - private DispatchResponse response(DispatchOrder order, List itemList, - List bolLineList) { - return response(order, itemList, bolLineList, null); - } - - private DispatchResponse response(DispatchOrder order, List itemList, - List bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { - return response(order,itemList,bolLineList,workflow,appointmentAllocations.references(List.of(order.getId())).getOrDefault(order.getId(),List.of())); - } - private DispatchResponse response(DispatchOrder order, List itemList, - List bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow, - List appointmentBatches) { - 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, itemRoute(itemList), - 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.getGrossWeightKg(), - item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(), - bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches, actions(order, itemList, workflow)); - } - - /** 返回与写命令相同的操作资格;权限仍由入口独立校验。 */ - private DispatchResponse.Actions actions(DispatchOrder order, List itemList, - com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { - if (workflow == null) workflow = fleetWorkflow.read(List.of(order.getDispatchNo())).get(order.getDispatchNo()); - var outbound = mutations().outbound(order, false); - boolean activeOutbound = outbound != null && !CANCELLED.equals(outbound.getStatus()); - String pendingReason = !PENDING_DEPARTURE.equals(order.getStatus()) ? "仅未执行的待发车排车可操作" - : Objects.requireNonNullElse(order.getPickedPackageCount(), 0) > 0 - || Objects.requireNonNullElse(order.getShippedPackageCount(), 0) > 0 ? "排车已开始拣货或发车" : null; - String mutationReason = workflow != null ? "请先在车次 " + workflow.tripNo() + " 中解除关联" : pendingReason; - String editReason = mutationReason != null ? mutationReason : activeOutbound ? "请先取消对应出库单" : null; - String removeReason = editReason != null ? editReason : itemList.stream() - .anyMatch(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) ? "换标按整单排车,请取消整张排车后重排" : null; - String transferReason = outbound != null ? (CANCELLED.equals(outbound.getStatus()) - ? "原出库已取消,请取消原排车后通过新预排重新安排" : "已转派送,请查看原出库及派送单") - : !Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART").contains(Objects.toString(order.getStatus(), "")) - ? "当前状态不能转派送" : itemList.isEmpty() || itemList.stream().anyMatch(item -> item.getWarehousePalletId() == null) - ? "请先完成收货并核对实物托盘" : null; - return new DispatchResponse.Actions(editReason == null, editReason, editReason == null, editReason, - removeReason == null, removeReason, transferReason == null, transferReason, - outbound == null ? null : outbound.getOutboundNo(), outbound == null ? null : outbound.getStatus()); - } - - /** 为本次变更路径集中组装数据访问,保持既有 Mapper 注入与事务连接。 */ - private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() { - return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository( - orders, items, locks, outboundOrders, candidates, relabelApplications, pdaTasks); - } - - private static String candidateRoute(List selected) { - return selected.stream().map(DispatchService::candidateFbaCode).filter(StringUtils::hasText) - .distinct().collect(Collectors.joining(",")); - } - - private static String itemRoute(List assignedItems) { - return assignedItems.stream().map(DispatchService::itemFbaCode).filter(StringUtils::hasText) - .distinct().collect(Collectors.joining(",")); - } - - private static String candidateFbaCode(DispatchPalletCandidate candidate) { - if ("PRIVATE".equals(candidate.destinationType())) { - return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode()) - .filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" ")); - } - return StringUtils.hasText(candidate.fbaWarehouseCode()) - ? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode()); - } - - private static boolean hasValidDestination(DispatchPalletCandidate candidate) { - if ("PRIVATE".equals(candidate.destinationType())) { - return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode()) - && StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone()); - } - return StringUtils.hasText(candidate.fbaWarehouseCode()) - || StringUtils.hasText(candidate.destinationWarehouseCode()); - } - - private static String itemFbaCode(DispatchItem item) { - return StringUtils.hasText(item.getFbaWarehouseCode()) - ? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode()); - } - - private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) { - List found = candidates.lockSelected(customerId, List.of(planPalletId)); - if (found.size() != 1) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Pallet is not available for dispatch"); - } - return found.get(0); - } - private static String selectionKey(DispatchPalletCandidate value) { return value.returnReceiptId() == null ? "PLAN:" + value.planPalletId() : "RETURN:" + value.returnReceiptId(); } - private static String selectionKey(DispatchItem value) { return value.getReturnReceiptId() == null ? "PLAN:" + value.getPlanPalletId() : "RETURN:" + value.getReturnReceiptId(); } - private DispatchPalletCandidate requireDraftSource(String customerId, DispatchRequests.DraftPallet request) { - if (!request.isValidSource()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择唯一实际来源"); - if (request.returnReceiptId() == null) return requireDraftCandidate(customerId, request.planPalletId()); - var found = returnCandidates.lockSelected(customerId, List.of(request.returnReceiptId())); - if (found.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "退回实收来源当前不可预排"); - return found.get(0); - } - - private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) { - DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey()); - if (active == null && candidate.warehousePalletId() != null && candidate.planPalletId() != null) { - // Read legacy plan-level locks created before physical-pallet locking was introduced. - active = locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()); - } - return active; - } - - private void requireDraftOwner(DispatchPalletLock lock, String operatorId, String draftToken) { - if (lock == null || !draftToken.equals(lock.getDraftToken()) - || !Objects.equals(trimToNull(operatorId), lock.getOperatorId())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet is locked by another dispatch draft"); - } - } - - private void releaseLock(DispatchPalletLock lock, LocalDateTime now) { - lock.setStatus("RELEASED"); - lock.setActiveKey(null); - lock.setReleasedAt(now); - lock.setUpdatedAt(now); - locks.updateById(lock); - } - - 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; - } - - private static String mergeCartonMarks(String regularMarks, String relabelMarks) { - LinkedHashSet values = new LinkedHashSet<>(); - for (String source : List.of(Objects.toString(regularMarks, ""), Objects.toString(relabelMarks, ""))) { - Arrays.stream(source.split(",")).map(String::trim).filter(StringUtils::hasText).forEach(values::add); - } - return values.isEmpty() ? null : String.join(",", values); - } - private static void validateSourceStage(String sourceStage) { if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL"); } } - private static List normalizeIds(List ids, String label) { - if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " must be non-empty"); - } - List normalized = ids.stream().distinct().sorted().toList(); - if (normalized.size() > 500) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " cannot exceed 500 items"); - } - return normalized; - } - private static String requireSingleCustomerScope(List customerIds, String source) { if (customerIds == null || customerIds.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, source + " were not found"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java index 887b5d037..2b7517eab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java @@ -24,8 +24,20 @@ public class ReturnRelabelDispatchStock { @Resource private ReturnInventoryService inventory; public List candidates(String customer, String orderNo, String relabelNo) { + return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo)); + } + + /** 整车候选只解析已经按仓限定且有界的原生退货记录。 */ + public List wholeCandidates(String warehouse,String orderNo) { + var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null); + if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围"); + return resolve(rows); + } + + /** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */ + private List resolve(List rows) { List result = new ArrayList<>(); - for (var candidate : candidates.nativeReturnRelabelAvailable(customer, orderNo, relabelNo)) { + for (var candidate : rows) { var pallet = physical.resolve(candidate.palletNo()); if (pallet == null || !String.valueOf(pallet.warehouseId()).equals(candidate.overseasWarehouseId()) || !Set.of("IDLE", "OCCUPIED").contains(pallet.status())) continue; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java index 0adec6c24..0f829934b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java @@ -9,17 +9,17 @@ import java.util.List; @Mapper public interface ReturnCartonStockReceiptMapper extends BaseMapper { record Shipment(Long pickId, Long dispatchItemId, String dispatchNo,Long productQuantity) { } - /** 原箱必须真实出库且逐箱归属于当前异常、派送、客户及原尾程订单。 */ + /** 整车与任务共享,原箱仍由本客户的明细、扫码及逐票归属证明真实发运。 */ @Select("SELECT p.id pick_id,di.id dispatch_item_id,d.dispatch_no,p.product_quantity FROM ll_exception_order e " + "JOIN ll_exception_return_item frozen ON frozen.exception_order_id=e.id AND frozen.customer_id=e.customer_id " + "AND frozen.outbound_pallet_item_id=#{itemId} AND frozen.return_carton_id=#{cartonId} AND frozen.inventory_position_id IS NULL AND frozen.approved_return_packages=1 AND frozen.received_packages=0 " + "JOIN ll_delivery_order delivery ON delivery.id=e.delivery_order_id AND delivery.customer_id=e.customer_id " + "JOIN ll_outbound_order o ON o.id=delivery.outbound_order_id AND o.customer_id=e.customer_id AND o.outbound_no=e.outbound_no AND o.status='OUTBOUNDED' " + "JOIN ll_outbound_pallet_item oi ON oi.outbound_order_id=o.id AND oi.customer_id=e.customer_id AND oi.id=#{itemId} " + - "JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id AND d.customer_id=e.customer_id " + + "JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id " + "JOIN ll_dispatch_item di ON di.dispatch_order_id=d.id AND di.customer_id=e.customer_id AND di.warehouse_pallet_id=oi.warehouse_pallet_id AND di.source_stage='RETURN_RELABEL' " + "AND di.inbound_code=oi.inbound_code AND COALESCE(di.destination_warehouse_code,'')=COALESCE(oi.destination_warehouse_code,'') " + - "JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id AND t.customer_id=e.customer_id " + + "JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id " + "JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND p.customer_id=e.customer_id AND p.dispatch_item_id=di.id AND p.return_carton_id=#{cartonId} AND p.inventory_position_id IS NULL AND p.packages=1 " + "JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=e.customer_id AND a.order_id=e.last_mile_order_id AND a.packages=1 " + "WHERE e.id=#{exceptionId} AND e.customer_id=#{customerId} AND e.status='PROCESSING' AND e.return_direction='OVERSEAS_WAREHOUSE'") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 83a79ea80..685804d1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -545,7 +545,7 @@ public class ExceptionOrderService { Map aggregated) { if (outbound.getDispatchOrderId() == null) return; String customerId = delivery.getCustomerId(); - DispatchPdaTask task = dispatchPdaTasks.findByDispatch(customerId, outbound.getDispatchOrderId()); + DispatchPdaTask task = dispatchPdaTasks.findByDispatch(outbound.getDispatchOrderId()); if (task == null) return; Map dispatchItemById = new LinkedHashMap<>(); dispatchItems.findByDispatchOrder(customerId, outbound.getDispatchOrderId()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java index fe2092ae3..6100f14f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java @@ -9,7 +9,7 @@ public interface FleetAppointmentImpactMapper { @Select(""" """) List trips(@Param("rows") List rows); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java index 243a9ffb7..3216dd0b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java @@ -61,7 +61,6 @@ public class FleetHandoverInspectionService { for (var binding : current) { var originalBinding = departedBindings.get(binding.getDispatchNo()); if (!seen.add(binding.getDispatchNo()) || originalBinding == null - || !Objects.equals(originalBinding.customerId(), binding.getCustomerId()) || !Objects.equals(originalBinding.packageCount(), binding.getPackageCount())) throw conflict("客户排车归属或箱数与原始发车不一致"); } @@ -69,7 +68,6 @@ public class FleetHandoverInspectionService { for (var binding : departedBindings.values()) { var fact = dispatches.transitEvidence(binding.dispatchNo(), lock); if (!Objects.equals(binding.dispatchNo(), fact.dispatchNo()) - || !Objects.equals(binding.customerId(), fact.customerId()) || !trip.getWarehouseId().toString().equals(fact.warehouseId()) || binding.packageCount() != fact.shippedPackages()) throw conflict("实际发运与车次客户、仓库或箱数不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java index 2e0a9966f..5452976d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -14,7 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; -/** Warehouse-scoped projection; exposes only the selected customer dispatch, never a whole-trip snapshot. */ +/** 由PDA绑定仓读取本车交接状态;实际派送归属逐行核验。 */ @Service @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) public class FleetHandoverPdaQuery { @@ -43,8 +43,14 @@ public class FleetHandoverPdaQuery { if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业"); var evidence = dispatches.transitEvidence(dispatchNo, false); - if (!Objects.equals(evidence.customerId(), binding.getCustomerId()) || !Long.toString(warehouseId).equals(evidence.warehouseId()) + if (!Long.toString(warehouseId).equals(evidence.warehouseId()) || !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound(); + var deliveryNumbers=new java.util.HashSet(); + if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row -> + row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank() + || !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null + || !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0) + || evidence.deliveries().stream().mapToLong(row->row.cargo().packages()).sum()!=evidence.shippedPackages())throw notFound(); var original = history.selectList(new LambdaQueryWrapper().eq(FleetTripHistory::getTripId, trip.getId()) .eq(FleetTripHistory::getAction, "DEPART")); if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录"); @@ -55,7 +61,7 @@ public class FleetHandoverPdaQuery { var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(), row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(), row.cargo().terminalAt(), row.transport())).toList(); - var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo))); + var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("dispatchNo", dispatchNo))); Handover handover = null; if (latest != null) { var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java index de5162f38..1d9ce39cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java @@ -2,8 +2,8 @@ package com.baiyue.lastleg.fleet.exception; import java.util.List; -/** Internal port response. Identity always comes from the original customer dispatch. */ -public record FleetTransitEvidence(String dispatchNo, String customerId, String warehouseId, +/** 整车发运事实;真实客户由每条Delivery.cargo携带。 */ +public record FleetTransitEvidence(String dispatchNo, String warehouseId, int shippedPackages, List deliveries) { public record Transport(String vehiclePlate, String driverName, String driverPhone) { } public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index 7c2d1aa87..46128f80c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -64,12 +64,12 @@ public class FleetBookingReservations { public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);} private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());} /** 按实际入仓来源绑定整票实收,合托不能掩盖外接承诺。 */ - public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List sources,String operator){ + public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,String customerId,List sources,String operator){ var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode)); for(var entry:bySource.entrySet()){ - var row=new FleetBookingCargoRepository(bookings).forInboundSource(dispatch.customerId(),entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue; + var row=new FleetBookingCargoRepository(bookings).forInboundSource(customerId,entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue; if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订"); - if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); + if(!Objects.equals(row.getCustomerId(),customerId)||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认"); var expected=new TreeMap(); for(var pallet:accepted.source().pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact); @@ -90,8 +90,17 @@ public class FleetBookingReservations { } } private void touch(FleetBookingEntity row,String operator){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(java.time.LocalDateTime.now(ZoneOffset.UTC));if(bookings.updateById(row)!=1)throw conflict("外接关联已变化");} + /** 父车次锁下读取有效外接占位支持的卸货目的地,不用展示响应推导。 */ + public Set requiredRouteDestinations(long tripId) { + var keys=new LinkedHashSet(); + for(var row:bookings.list(tripId)) if(unbound(row)||"BOUND".equals(row.getStatus())) { + if(row.getDestinationKey()==null || row.getDestinationKey().isBlank())throw conflict("已确认外接预订缺少真实卸货目的地"); + keys.add(row.getDestinationKey()); + } + return Collections.unmodifiableSet(keys); + } public void requireRoute(FleetTripEntity trip,List stops) { - for(var row:bookings.list(trip.getId())) if((unbound(row)||"BOUND".equals(row.getStatus())) && index(stops,row.getDestinationKey())<1) + for(var key:requiredRouteDestinations(trip.getId())) if(index(stops,key)<1) throw conflict("路线仍须包含已确认外接预订的卸货站"); } private void add(List loads,List stops,FleetBookingEntity row){ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index b7f6a252d..fdf8ad43d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -38,6 +38,7 @@ public class FleetBookingService { private final FleetQuoteCandidateService quotes; @jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders; @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; + @jakarta.annotation.Resource private FleetDispatchGateway dispatches; @jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands; public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, @@ -201,10 +202,11 @@ public class FleetBookingService { planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence); } row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); - var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo)); + bindToWhole(trip,row,request.dispatchNo(),operator); + history.recordExecution(trip,trip.getRecordVersion(),"BOOKING_BIND","关联外接预订 "+bookingNo,operator); var result=booking(trip,bookingNo); if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物"); - return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false); + return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(result),false); } @Transactional public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) { @@ -225,17 +227,27 @@ public class FleetBookingService { var plan=execution.capacityPlan(tripNo); if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入"); reservations.segments(trip,plan.capacityPlan(),row.getId(),row); - String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator); - var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo)); + String dispatchNo=planning.resolveIncoming(tripNo,row.getCustomerId(),evidence,operator,"FLEET_INCOMING:"+hash(java.util.List.of(bookingNo,request.idempotencyKey()))); + trip=trip(tripNo,true);bindToWhole(trip,row,dispatchNo,operator); + history.recordExecution(trip,trip.getRecordVersion(),"BOOKING_ARRANGE","实收核对后排入本车 "+bookingNo,operator); row=booking(trip,bookingNo); if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源"); row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); - var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false); + var result=new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),false); var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator); command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash); command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command); return result; } + /** 外接来源只能绑定该车已有的一对一主单,不能将另一整车挂到当前车次。 */ + private void bindToWhole(FleetTripEntity trip,FleetBookingEntity booking,String number,String operator) { + var identity=history.dispatchIdentity(number); + if(identity.tripId()!=trip.getId())throw conflict("外接实收只能加入本车唯一排车主单"); + var accepted=reservations.receipt(booking);if(accepted==null)throw conflict("此外接预订缺少实际收货依据"); + planning.validateIncomingDispatch(number,booking.getCustomerId(),accepted.source()); + var snapshot=dispatches.lockForPlanning(number); + reservations.bindIncoming(trip,snapshot,booking.getCustomerId(),dispatches.sourceCargo(number,booking.getCustomerId()),operator); + } private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){ if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java index 9b4516de6..b46c15da9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java @@ -9,17 +9,18 @@ import org.springframework.web.server.ResponseStatusException; @Service public class FleetDispatchBindingGuard { private final FleetTripDispatchMapper bindings; - private final com.fasterxml.jackson.databind.ObjectMapper json; - public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, com.fasterxml.jackson.databind.ObjectMapper json) { this.bindings = bindings; this.json = json; } + private final FleetDispatchGateway dispatches; + public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches) { this.bindings = bindings; this.dispatches = dispatches; } /** Dispatch -> task -> binding locks; never acquire the parent trip lock from a PDA pick. */ @Transactional(propagation = Propagation.MANDATORY) public void requirePickingApproved(long dispatchId) { var binding = bindings.lockByDispatch(dispatchId); if (binding == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "请先关联车次并通过调度审核"); - var review = FleetTripReview.read(json, binding.getReviewJson()); - if (!"APPROVED".equals(review.status())) throw new ResponseStatusException(HttpStatus.CONFLICT, - "REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"); + var fact=dispatches.workflows(java.util.List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); + if(fact==null || !"ACTIVE".equals(fact.status()) || !"LOADING".equals(fact.stage()) + || !"APPROVED".equals(fact.dispatchReview().status()) || !"APPROVED".equals(fact.loadingReview().status())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"请先完成本车两级审核再扫码"); } @Transactional(propagation = Propagation.MANDATORY) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java index 5891cafe9..c34618809 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java @@ -4,9 +4,14 @@ import java.time.Instant; /** Customer dispatches remain owned by dispatch; fleet only orchestrates their physical trip. */ public interface FleetDispatchGateway { - record Snapshot(long dispatchId, String dispatchNo, String customerId, String warehouseId, int packageCount) { } + /** 审核唯一事实来自整车主单,Fleet只持有物理资源关联。 */ + record Workflow(String status,String stage,long recordVersion, + FleetTripModels.ReviewState dispatchReview,FleetTripModels.ReviewState loadingReview) { } + java.util.Map workflows(java.util.List dispatchNos); + record Snapshot(long dispatchId, String dispatchNo, String warehouseId, int packageCount) { } record SourceCargo(String inboundCode,String warehousePalletId,String palletNo,int packages,String destinationKey) { } - java.util.List sourceCargo(String dispatchNo); + java.util.List customers(String dispatchNo); + java.util.List sourceCargo(String dispatchNo,String customerId); record Assignment(String vehiclePlate, String driverName, String driverPhone) { } static String destinationKey(String type,String code,String fba,String address,String postalCode) { @@ -19,23 +24,17 @@ public interface FleetDispatchGateway { /** Locks the dispatch and rejects any picking or departure already performed. */ Snapshot lockForPlanning(String dispatchNo); - /** Allows pre-departure resource/time changes without changing cargo or recorded picking. */ - Snapshot lockForAssignment(String dispatchNo); - /** Original dispatch command; caller must first detach the dispatch from its vehicle. */ - void removeItem(String dispatchNo,long itemId); - /** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */ - void assign(Snapshot dispatch, Assignment assignment); FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock); FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock); record ReturnedReceipt(String itemId,String palletId,int packages,Instant receivedAt) { } record TerminalDelivery(String customerId,String deliveryNo,String destinationKey,int packages, Instant terminalAt,String status,long recordVersion,java.util.List receipts) { } - record TerminalEvidence(String dispatchNo,String customerId,String warehouseId,int shippedPackages, + record TerminalEvidence(String dispatchNo,String warehouseId,int shippedPackages, java.util.List deliveries) { } /** Narrow execution facts for appointment eligibility, derived from the original verified evidence. */ default TerminalEvidence terminalEvidence(String dispatchNo,boolean lock) { var evidence=transitEvidence(dispatchNo,lock); - return new TerminalEvidence(evidence.dispatchNo(),evidence.customerId(),evidence.warehouseId(),evidence.shippedPackages(), + return new TerminalEvidence(evidence.dispatchNo(),evidence.warehouseId(),evidence.shippedPackages(), evidence.deliveries().stream().map(d->new TerminalDelivery(d.cargo().customerId(),d.cargo().deliveryNo(), d.cargo().destinationKey(),d.cargo().packages(),d.cargo().terminalAt(),d.status(),d.recordVersion(), d.cargo().returnedCargo().stream().map(r->new ReturnedReceipt(r.itemId(),r.palletId(),r.packages(),r.receivedAt())).toList())).toList()); @@ -44,6 +43,4 @@ public interface FleetDispatchGateway { com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock); /** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */ void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment); - FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator, - String idempotencyKey, String remark, Instant departedAt); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java index 56a5d1fc3..cfb3ffba9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java @@ -4,8 +4,6 @@ import com.baiyue.common.core.api.PageResult; /** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */ public interface FleetDispatchPlanningGateway { - record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { } - PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size); void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt); - String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator); + String resolveIncoming(String tripNo,String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator,String commandKey); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java index 2c9fef6c2..b084f0443 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java @@ -13,31 +13,28 @@ public class FleetDispatchWorkflowQuery { public record Review(String status, String reason, java.time.Instant reviewedAt) { } public record Workflow(String tripNo, Review dispatchReview, Review loadingReview) { } private final FleetTripDispatchMapper bindings; - private final FleetTripMapper trips; - private final ObjectMapper json; + private final FleetDispatchGateway dispatches; - public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, ObjectMapper json) { - this.bindings = bindings; this.trips = trips; this.json = json; + public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches) { + this.bindings = bindings; this.dispatches = dispatches; } public Map read(List dispatchNos) { if (dispatchNos.isEmpty()) return Map.of(); var members = bindings.findByDispatchNos(dispatchNos); if (members.isEmpty()) return Map.of(); - var parents = trips.selectBatchIds(members.stream().map(FleetTripDispatch::getTripId).distinct().toList()) - .stream().collect(Collectors.toMap(FleetTripEntity::getId, trip -> trip)); + var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList()); Map result = new HashMap<>(); for (var member : members) { - var trip = parents.get(member.getTripId()); - if (trip == null) throw new IllegalStateException("关联车次不存在"); + var fact = facts.get(member.getDispatchNo()); + if (fact == null) throw new IllegalStateException("关联整车不存在"); result.put(member.getDispatchNo(), new Workflow(member.getTripNo(), - review(member.getReviewJson()), review(trip.getLoadingReviewJson()))); + review(fact.dispatchReview()), review(fact.loadingReview()))); } return result; } - private Review review(String value) { - var state = FleetTripReview.read(json, value); - return new Review(state.status(), state.reason(), state.reviewedAt()); + private Review review(FleetTripModels.ReviewState value) { + return new Review(value.status(), value.reason(), value.reviewedAt()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java index cb9951283..5ac871213 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java @@ -9,6 +9,21 @@ import java.util.List; public final class FleetExecutionModels { private FleetExecutionModels() { } + /** 整车主单自动建立资源记录的原生输入,不引用排车持久化实体。 */ + public record DispatchVehicle(String mode, Long vehicleId, String plate, String carrierName, + Integer palletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm, + List evidenceFileIds) { } + public record DispatchDriver(String mode, Long driverId, String name, String phone, String licenseNo, + java.time.LocalDate licenseExpiresOn, List evidenceFileIds, + Instant expectedTaskEndAt) { } + public record DispatchShellRequest(long dispatchId, String dispatchNo, long warehouseId, + String idempotencyKey, String commandHash, Instant plannedDepartureAt, + DispatchVehicle vehicle, DispatchDriver driver, String remark) { } + public record DispatchShell(long tripId, String tripNo, String warehouseId, String warehouseCode, + String warehouseName, DispatchVehicle vehicle, DispatchDriver driver, + String vehicleStatus) { } + public record DispatchShellLock(long tripId, String tripNo, String warehouseId) { } + public record DispatchStop(String name, String address, String destinationKey) { } public record Cargo(String dispatchNo, String customerId, String dispatchItemId, String warehousePalletId, String palletNo, int packages, String destinationKey, String destinationLabel) { } public record Manifest(String dispatchNo, String warehouseId, String vehiclePlate, String driverName, String driverPhone, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java index dccb04145..a1675fc05 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java @@ -12,6 +12,9 @@ public interface FleetResourceMapper { List candidates(@Param("kind") String kind, @Param("ids") List ids); @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}") Assignment find(@Param("kind") String kind, @Param("id") long id); + /** 草稿批量转换先锁齐资源占用身份,不能在第一个组来源之后再等下一组资源。 */ + @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} FOR UPDATE") + Assignment lock(@Param("kind")String kind,@Param("id")long id); @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id") List byTrip(@Param("tripId") long tripId); @Insert("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES(#{kind},#{id},#{tripId},#{tripNo})") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java index 580f9122a..94bd1ecf9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java @@ -86,11 +86,11 @@ public class FleetTripCompletionService { if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus()))) throw bad("请确认车辆结束任务后的实际状态"); if (!vehicle && request.vehicleStatus() != null) throw bad("结束司机任务不能修改车辆状态"); - if (vehicle) { + if (vehicle && trip.getVehicleId()!=null) { var current = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在")); requireRelease("VEHICLE", trip.getVehicleId(), trip.getId()); vehicleService.changeStatus(trip.getVehicleId(), operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), current.recordVersion())); - } else { + } else if(!vehicle && trip.getDriverId()!=null) { // A now-expired or disabled driver may end their task; this never re-enables their qualification. requireRelease("DRIVER", trip.getDriverId(), trip.getId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java index 065e973b1..cca5f30ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java @@ -12,12 +12,9 @@ public class FleetTripDispatch { @TableId(type = IdType.INPUT) private Long dispatchId; private String dispatchNo; - private String customerId; private Long tripId; private String tripNo; private Integer packageCount; private String boundBy; private LocalDateTime boundAt; - @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) - private String reviewJson; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java index 3de088e92..0962f424a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java @@ -6,6 +6,20 @@ import java.util.List; @Mapper public interface FleetTripDispatchMapper extends BaseMapper { + /** 整车主单一对一关联,无客户头部或绑定级审核。 */ + @Insert("INSERT INTO ll_fleet_trip_dispatch(dispatch_id,dispatch_no,trip_id,trip_no,package_count,bound_by,bound_at) " + + "VALUES(#{dispatchId},#{dispatchNo},#{tripId},#{tripNo},0,#{operator},#{at})") + int insertWholeDispatch(@Param("dispatchId") long dispatchId, @Param("dispatchNo") String dispatchNo, + @Param("tripId") long tripId, @Param("tripNo") String tripNo, + @Param("operator") String operator, @Param("at") java.time.LocalDateTime at); + + /** 只锁司机档案身份,历史命令恢复不以当前证照资格重新执行。 */ + @Select("SELECT id FROM ll_fleet_driver WHERE id=#{id} FOR UPDATE") + Long lockDriverIdentity(@Param("id") long id); + + /** 主单货量和唯一资源关系在父锁内同步,不按客户子单累加。 */ + @Update("UPDATE ll_fleet_trip_dispatch SET package_count=#{packages} WHERE dispatch_no=#{number}") + int updateWholeQuantity(@Param("number") String dispatchNo, @Param("packages") int packages); @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE trip_id=#{tripId} ORDER BY dispatch_id") List listByTrip(@Param("tripId") long tripId); @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no=#{dispatchNo}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index 1c377d3e0..2fb697ea2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -41,6 +41,98 @@ public class FleetTripExecutionService { this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations; } + /** 对排车域暴露原生资源创建入口,不暴露Fleet实体和Mapper。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) { + return history.createDispatchShell(operator, request); + } + /** 资源批量预锁不得在第一组来源锁之后才执行。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDispatchCreationResources(List vehicles,List drivers){history.lockDispatchCreationResources(vehicles,drivers);} + + /** 建立主单后的唯一关系,不让前端执行旧绑定动作。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void linkDispatchShell(String operator,long dispatchId,String dispatchNo,String tripNo) { + history.linkDispatchShell(operator,dispatchId,dispatchNo,tripNo); + } + + /** 保持排车域只依赖Fleet原生应用入口。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo) { return history.lockDispatchShell(dispatchNo); } + /** 全范围父级和资源预锁,调用方随后一次收齐全部dispatch。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockExistingDispatchShells(List dispatchNos){history.lockExistingDispatchShells(dispatchNos);} + + /** 只读返回唯一资源身份。 */ + public FleetExecutionModels.DispatchShellLock dispatchIdentity(String no){return history.dispatchIdentity(no);} + /** 汇总仅取得资源父身份锁,不申请车辆司机主档锁或释放资源。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchCompletion(String no){return history.lockDispatchCompletion(no);} + /** 装车读数与真实站序共同展示。 */ + public List dispatchStops(String no){return history.dispatchStops(no);} + /** 按排车父事务维护真实站序。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void reorderDispatchStops(String no,String operator,List keys,String reason){history.reorderDispatchStops(no,operator,keys,reason);} + /** 主单取消与资源占用释放同事务协调。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void cancelDispatchShell(String no,String operator,String reason){history.cancelDispatchShell(no,operator,reason);} + + /** 整车主流程已持父锁;容量沿原策略校验,保存实载不会扣库存或发车。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void confirmDispatchLoad(String no,String operator,String manifestHash,List cargo, + List measurements,String reason) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车已开始运输"); + var ownSegments=capacity(trip,cargo,measurements); + var plan=new FleetExecutionModels.PlannedLoad(manifestHash,cargo,measurements,ownSegments,operator,Instant.now()); + var segments=bookingReservations.segments(trip,plan,null,null); + // 同时核对原有外接预订占用;不因新主流程绕过已确认容量。 + var load=new FleetExecutionModels.ConfirmedLoad(manifestHash,cargo,measurements,segments,operator,Instant.now()); + trip.setLoadJson(write(load));history.recordExecution(trip,trip.getRecordVersion(),"DISPATCH_CONFIRM_LOAD",reason,operator); + } + + /** 已锁父级后复验已确认实载及原预约资格,不在此扣库存或锁客户单据。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void requireDispatchDeparture(String no,String manifestHash) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对"); + if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId()); + bookingReservations.segments(trip,new FleetExecutionModels.PlannedLoad(load.manifestHash(),load.cargo(),load.pallets(),load.segments(),load.operatorId(),load.confirmedAt()),null,null); + appointmentAllocations.requireDeparture(no); + } + + /** 客户单据和来源均已核对后记录实际运输状态,资源占用保留到原任务结束入口。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void departDispatchShell(String no,String operator,Instant at,String reason) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车资源任务已开始执行"); + trip.setStatus("IN_TRANSIT");trip.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + history.recordExecution(trip,trip.getRecordVersion(),"DEPART",reason,operator); + } + + /** 编辑先锁齐全部新旧身份,保持全局资源锁序。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String no,Long vehicle,Long driver) { return history.lockDispatchShell(no,vehicle,driver); } + + /** 当前资格读取不借主单头客户或客户端主档显示字段。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell dispatchResources(String no,FleetExecutionModels.DispatchVehicle vehicle,FleetExecutionModels.DispatchDriver driver) { + return history.dispatchResources(no,vehicle,driver); + } + + /** 同事务保存已核实的新资源与计划。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void saveDispatchResources(String no,String operator,FleetExecutionModels.DispatchShell actual,Instant plannedAt,String remark) { + history.saveDispatchResources(no,operator,actual,plannedAt,remark); + } + + /** 父事务内更新一车货量与真实目的地。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void updateDispatchCargo(String dispatchNo, int packages, List stops) { + history.updateDispatchCargo(dispatchNo,packages,stops); + } + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) public FleetExecutionModels.Planning capacityPlan(String tripNo) { var trip = requireTrip(tripNo, false); @@ -76,7 +168,6 @@ public class FleetTripExecutionService { private String planningBlockReason(FleetTripEntity trip, List cargo) { if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量"; - if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积"; String vehicleReason = currentVehicleBlockReason(trip, false); if (vehicleReason != null) return vehicleReason; if (cargo.isEmpty()) return "请先关联本车自有货排车单"; @@ -87,7 +178,10 @@ public class FleetTripExecutionService { /** Caller holds the parent trip lock before requesting a vehicle write lock. */ public String currentVehicleBlockReason(FleetTripEntity trip, boolean lock) { - if (trip.getVehicleId() == null) return "请先分配车辆"; + if (trip.getVehicleId() == null) return trip.getVehiclePlate()!=null && !trip.getVehiclePlate().isBlank() + && trip.getMaxPalletSpaces()!=null && trip.getMaxPalletSpaces()>0 + && trip.getMaxLoadKg()!=null && trip.getMaxLoadKg().signum()>0 + && trip.getMaxVolumeCbm()!=null && trip.getMaxVolumeCbm().signum()>0?null:"请先确认本趟临时车辆资料和实际容量"; var vehicle = (lock ? vehicles.lockById(trip.getVehicleId()) : vehicles.findById(trip.getVehicleId())).orElse(null); if (vehicle == null || !"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) return "车辆当前不可用,请重新分配车辆并核对计划容量"; @@ -107,146 +201,7 @@ public class FleetTripExecutionService { return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, hash(write(cargo)), load(trip), blockReason(trip, manifest)); } - @Transactional - public FleetExecutionModels.Loading confirmLoad(String tripNo, String operator, FleetExecutionModels.Confirm request) { - var trip = requireTrip(tripNo, true); - requireVersionAndPlan(trip, request.recordVersion()); - var manifest = manifests(trip, true); - String block = blockReason(trip, manifest); - if (block != null) throw conflict(block); - var cargo = cargo(manifest); String fingerprint = hash(write(cargo)); - if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对装载"); - var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList(); - var segments = capacity(trip, cargo, pallets); - var confirmed = new FleetExecutionModels.ConfirmedLoad(fingerprint, cargo, pallets, segments, operator, Instant.now()); - trip.setLoadJson(write(confirmed)); - history.recordExecution(trip, request.recordVersion(), "CONFIRM_LOAD", request.reason(), operator); - return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, fingerprint, confirmed, null); - } - @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) - public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) { - var binding = bindings.findByDispatchNo(dispatchNo); - if (binding == null) return missingGate(); - return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding); - } - - @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) - public Map gates(String warehouseId, List numbers) { - if (numbers.isEmpty()) return Map.of(); - var result = new HashMap(); - numbers.forEach(number -> result.put(number, missingGate())); - var byTrip = new HashMap(); - for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate( - byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding)); - return result; - } - - private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) { - var trip = requireTrip(tripNo, false); - requireWarehouse(trip, warehouseId); - var manifest = manifests(trip, false); - String reason = blockReason(trip, manifest); - String warning = null; - if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); - if (reason == null) reason = loadingReviewBlockReason(trip); - if (reason == null) for (var member : bindings.listByTrip(trip.getId())) { - reason = appointmentAllocations.departureBlockReason(member.getDispatchNo()); - if (reason != null) break; - } - if (reason == null) { - var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); - if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对"; - else reason = driverBlockReason(trip, driver); - if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险"; - } - boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null; - return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning, - canPick, canPick ? null : "当前车次不允许继续拣货"); - } - - private static FleetExecutionModels.Gate missingGate() { - return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核"); - } - private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) { - if (!gate.canPick()) return gate; - var review = FleetTripReview.read(json, binding.getReviewJson()); - String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) - ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"; - return gate.withPicking(reason == null, reason); - } - private String loadingReviewBlockReason(FleetTripEntity trip) { - var review = FleetTripReview.read(json, trip.getLoadingReviewJson()); - return "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "理货审核已打回:" + review.reason() : "请先通过理货审核再发车"; - } - - @Transactional - public FleetTripModels.Detail reviewLoading(String tripNo, String operator, FleetTripModels.Review request) { - var trip = requireTrip(tripNo, true); - requireVersionAndPlan(trip, request.recordVersion()); - String reason = FleetTripReview.reason(request); - if ("APPROVED".equals(request.decision())) { - if (trip.getDriverId() == null) throw conflict("请先分配司机"); - var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); - String driverBlock = driverBlockReason(trip, driver); - if (driverBlock != null) throw conflict(driverBlock); - var manifest = manifests(trip, true); - String block = blockReason(trip, manifest); - if (block == null) block = loadBlockReason(trip, cargo(manifest)); - if (block != null) throw conflict(block); - } - trip.setLoadingReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, Instant.now(), request.recordVersion()))); - history.recordExecution(trip, request.recordVersion(), "LOADING_REVIEW", reason, operator); - return history.detail(tripNo); - } - - @Transactional - public FleetExecutionModels.Departed depart(String warehouseId, String dispatchNo, String operator, FleetExecutionModels.Depart request) { - var binding = bindings.findByDispatchNo(dispatchNo); - if (binding == null) throw conflict("请先在平台将排车单关联车次"); - var trip = requireTrip(binding.getTripNo(), true); - requireWarehouse(trip, warehouseId); - // The binding may have changed while waiting for the trip lock. Never ship a stale parent. - var members = bindings.listByTrip(trip.getId()); - if (members.stream().noneMatch(member -> member.getDispatchNo().equals(dispatchNo))) throw conflict("排车单所属车次已变化,请刷新"); - String requestHash = hash(write(request)); - if (trip.getDepartureKey() != null) { - if (!trip.getDepartureKey().equals(request.idempotencyKey()) || !trip.getDepartureHash().equals(requestHash)) - throw conflict("车次已发车,发车请求标识或内容不一致"); - return read(trip.getDepartureResultJson(), FleetExecutionModels.Departed.class).withReplay(); - } - requireVersionAndPlan(trip, request.recordVersion()); - // Resource lock order matches trip assignment: trip -> vehicle -> driver -> customer dispatches. - if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); - var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在")); - if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region()) - || !Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate()) || !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces()) - || !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg()) || !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm())) - throw conflict("车辆状态或容量已变化,请重新确认车次"); - requireResource("VEHICLE", trip.getVehicleId(), trip.getId()); requireResource("DRIVER", trip.getDriverId(), trip.getId()); - var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); - String driverBlock = driverBlockReason(trip, driver); - if (driverBlock != null) throw conflict(driverBlock); - var manifest = manifests(trip, true); - String reason = blockReason(trip, manifest); - if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); - if (reason == null) reason = loadingReviewBlockReason(trip); - if (reason != null) throw conflict(reason); - // Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result. - for(var member:members) appointmentAllocations.requireDeparture(member.getDispatchNo()); - Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); - trip.setStatus("DEPARTING"); - if (trips.updateById(trip) != 1) throw conflict("车次状态已变化"); - var results = new ArrayList(); - for (var member : members) results.add(dispatches.depart(warehouseId, member.getDispatchNo(), operator, - trip.getTripNo() + ":" + member.getDispatchNo(), request.remark(), now)); - trip.setStatus("IN_TRANSIT"); trip.setDepartedAt(LocalDateTime.ofInstant(now, ZoneOffset.UTC)); - var result = new FleetExecutionModels.Departed(trip.getTripNo(), "IN_TRANSIT", now, - results.stream().mapToLong(FleetExecutionModels.DepartedDispatch::shippedPackages).sum(), List.copyOf(results), false); - trip.setDepartureKey(request.idempotencyKey()); trip.setDepartureHash(requestHash); trip.setDepartureResultJson(write(result)); - history.recordExecution(trip, request.recordVersion(), "DEPART", request.remark(), operator); - return result; - } private List manifests(FleetTripEntity trip, boolean lock) { return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList(); @@ -257,11 +212,13 @@ public class FleetTripExecutionService { } private String blockReason(FleetTripEntity trip, List manifests) { if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划"; - if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机"; + String vehicleReason=currentVehicleBlockReason(trip,false);if(vehicleReason!=null)return vehicleReason; + if(trip.getDriverName()==null || trip.getDriverName().isBlank())return "请先确认本趟司机资料"; if (manifests.isEmpty()) return "车次尚未关联排车单"; if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联"; for (var binding : bindings.listByTrip(trip.getId())) { - if (!FleetTripReview.approved(json, binding.getReviewJson())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核"; + var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); + if (fact==null || !"APPROVED".equals(fact.dispatchReview().status())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核"; } for (var manifest : manifests) { if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致"; @@ -271,13 +228,6 @@ public class FleetTripExecutionService { } return null; } - private String loadBlockReason(FleetTripEntity trip, List cargo) { - var load = load(trip); - if (load == null) return "请在平台确认本车实际装载"; - if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认"; - try { capacity(trip, cargo, load.pallets()); return null; } - catch (ResponseStatusException error) { return error.getReason(); } - } private List capacity(FleetTripEntity trip, List cargo, List pallets) { List stops; @@ -314,19 +264,11 @@ public class FleetTripExecutionService { var claim = resources.find(kind, id); if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化"); } - private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) { - if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案"; - if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照"; - return null; - } private FleetTripEntity requireTrip(String tripNo, boolean lock) { var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); return trip; } - private static void requireWarehouse(FleetTripEntity trip, String warehouseId) { - if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次"); - } /** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */ private static void requireVersionAndPlan(FleetTripEntity trip, long version) { if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java index 24ae4d6f6..f92b9dd3e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java @@ -36,10 +36,10 @@ public final class FleetTripModels { public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) { public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); } } - public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review, + public record DispatchBinding(String dispatchNo, int packageCount, String boundBy, Instant boundAt, ReviewState review, List appointmentBatches) { } /** Original bound references only; these quantities never authorize current allocation. */ - public record PreviousDispatch(String dispatchNo,String customerId,long recordVersion,Instant recordedAt, + public record PreviousDispatch(String dispatchNo,long recordVersion,Instant recordedAt, List appointmentBatches) { } public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName, String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java deleted file mode 100644 index 7e5848ca2..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.baiyue.lastleg.fleet.trip; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.security.core.context.SecurityContextHolder; -import java.util.*; -import java.time.LocalDateTime; -import java.time.ZoneOffset; - -/** Shared by dispatch and appointment consumers; all business writes commit together. */ -@Service -public class FleetTripPreparationService { - public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion, - @Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId, - @NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos, - @NotBlank @Size(min=8,max=128) String idempotencyKey) { } - private final FleetTripService service; - private final FleetTripMapper trips; - private final FleetCapacityOfferCommandMapper commands; - private final ObjectMapper json; - public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) { - this.service=service;this.trips=trips;this.commands=commands;this.json=json; - } - @Transactional - public FleetTripModels.Detail prepare(String operator,Prepare request) { - boolean create=request.tripNo()==null||request.tripNo().isBlank(); - if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); } - permission("platform:fleet:trip:edit"); - if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择"); - if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机"); - if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划"); - var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo()); - if(!create&&initial==null)throw conflict("所选车次不存在"); - String payload=write(request); - if(initial!=null) { - var locked=trips.lockByNo(initial.getTripNo()); - var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); - if(previous!=null) { - if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); - try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} - } - if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次"); - } - FleetTripModels.Detail detail; - if(create) { - var p=request.plan(); - detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey())); - // Concurrent same-key create may have committed while waiting for its unique constraint. - var locked=trips.lockByNo(detail.trip().tripNo()); - var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); - if(previous!=null) { - if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); - try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} - } - detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源")); - } else { - detail=service.detail(request.tripNo()); - if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对"); - } - for(String number:request.dispatchNos().stream().sorted().toList()) - detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次")); - var row=trips.findByNo(detail.trip().tripNo()); - var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator); - command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload)); - command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command); - return detail; - } - private static void permission(String name) { - var auth=SecurityContextHolder.getContext().getAuthentication(); - if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限"); - } - private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} - private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} - private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java index cf51f9ffb..1f9bbd79a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java @@ -24,6 +24,16 @@ public class FleetTripRepository { public FleetTripEntity lockCreated(String operator,String key) { return trips.lockCreated(operator,key); } /** 新增车次记录。 */ public int insertTrip(FleetTripEntity row) { return trips.insert(row); } + /** 保存整车的一对一资源关系,不伪造客户或子排车。 */ + public int insertWholeDispatch(long dispatchId, String dispatchNo, FleetTripEntity trip, String operator) { + return bindings.insertWholeDispatch(dispatchId, dispatchNo, trip.getId(), trip.getTripNo(), operator, trip.getCreatedAt()); + } + /** 普通读定位唯一整车关系,父锁后必须重新验证。 */ + public FleetTripDispatch wholeBinding(String number) { return bindings.findByDispatchNo(number); } + /** 只锁司机身份,不因资格过期阻断原命令恢复。 */ + public Long lockDriverIdentity(long id) { return bindings.lockDriverIdentity(id); } + /** 同步整车资源快照货量。 */ + public int wholeQuantity(String number, int packages) { return bindings.updateWholeQuantity(number,packages); } /** 按业务号查询车次。 */ public FleetTripEntity findTrip(String number) { return trips.findByNo(number); } /** 按业务号加锁读取车次。 */ @@ -50,6 +60,8 @@ public class FleetTripRepository { public int insertHistory(FleetTripHistory row) { return histories.insert(row); } /** 查询资源实时占用。 */ public FleetResourceMapper.Assignment resource(String kind,long id) { return resources.find(kind,id); } + /** 批量正式建车的资源占用在取来源锁之前统一锁齐。 */ + public FleetResourceMapper.Assignment lockResource(String kind,long id){return resources.lock(kind,id);} /** 批量读取候选资源占用,避免列表逐项查询。 */ public List resourceOccupancy(String kind,List ids) { return resources.candidates(kind,ids); } /** 唯一键防止车辆或司机同时承接两趟任务。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 5d44b4731..5f9bcb3e6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -93,11 +93,7 @@ public class FleetTripService { }).toList(); } - /** 创建派送用途车次,保留原有请求幂等语义。 */ - @Transactional - public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) { - return createPlan(operator, request, "DELIVERY"); - } + /** 取货车次首次历史即标识取货用途,复用统一的资源和计划规则。 */ @Transactional @@ -105,6 +101,279 @@ public class FleetTripService { return createPlan(operator, request, "PICKUP"); } + /** 主单创建事务内建立空资源记录,允许未知计划/临时资料,审核时再核齐。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) { + var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); + var trip = new FleetTripEntity(); + trip.setId(IdWorker.getId()); + trip.setTripNo("TR-" + new java.math.BigInteger(UUID.randomUUID().toString().replace("-", ""), 16).toString(36).toUpperCase(Locale.ROOT)); + trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(request.commandHash()); + trip.setStatus("PLANNED"); trip.setTripPurpose("DELIVERY"); trip.setWarehouseId(request.warehouseId()); + trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); + trip.setPlannedDepartureAt(request.plannedDepartureAt() == null ? null : utc(request.plannedDepartureAt())); + trip.setStopsJson("[]"); trip.setRemark(request.remark()); trip.setRecordVersion(0L); + trip.setCreatedBy(operator); trip.setUpdatedBy(operator); trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt()); + if (repository.insertTrip(trip) != 1) throw conflict("整车资源记录创建失败"); + + // 创建记录首先取得本trip写锁,随后只按车辆→司机/资源的顺序取锁。 + var vehicle = request.vehicle(); + String vehicleStatus = null; + if (vehicle != null && "MASTER".equals(vehicle.mode())) { + if (vehicle.vehicleId() == null) throw bad("请选择车辆档案"); + var actual = vehicles.lockById(vehicle.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); + if (!"OVERSEAS".equals(actual.region()) || !"IN_SERVICE".equals(actual.status())) throw bad("请选择在役海外车辆"); + vehicle = new FleetExecutionModels.DispatchVehicle("MASTER", actual.id(), actual.plateNumber(), + actual.driverOrDispatchCompany(), actual.maxPalletSpaces(), actual.maxLoadKg(), actual.maxVolumeCbm(), List.of()); + vehicleStatus = actual.status(); + } else if (vehicle != null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId() != null)) { + throw bad("临时车辆不得关联或伪造车辆档案"); + } + var driver = request.driver(); + if (driver != null && "MASTER".equals(driver.mode())) { + if (driver.driverId() == null) throw bad("请选择司机档案"); + var actual = drivers.requireDispatchable(driver.driverId(), driver.expectedTaskEndAt()); + if (!"OVERSEAS".equals(actual.details().region())) throw bad("请选择海外司机"); + driver = new FleetExecutionModels.DispatchDriver("MASTER", Long.valueOf(actual.id()), actual.details().name(), + actual.details().phone(), actual.details().licenseNumber(), actual.details().licenseExpiresOn(), + actual.licenseFileId() == null ? List.of() : List.of(Long.valueOf(actual.licenseFileId())), driver.expectedTaskEndAt()); + } else if (driver != null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId() != null)) { + throw bad("临时司机不得关联或伪造司机档案"); + } + if (vehicle != null) { + if (vehicle.vehicleId() != null) reserve("VEHICLE", vehicle.vehicleId(), trip); + trip.setVehicleId(vehicle.vehicleId()); trip.setVehiclePlate(vehicle.plate()); + trip.setMaxPalletSpaces(vehicle.palletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); + } + if (driver != null) { + if (driver.driverId() != null) reserve("DRIVER", driver.driverId(), trip); + trip.setDriverId(driver.driverId()); trip.setDriverName(driver.name()); trip.setDriverPhone(driver.phone()); + trip.setExpectedDriverEndAt(driver.expectedTaskEndAt() == null ? null : utc(driver.expectedTaskEndAt())); + } + if (repository.updateTrip(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()) + .eq(FleetTripEntity::getRecordVersion, 0L)) != 1) + throw conflict("整车资源关联保存失败"); + return new FleetExecutionModels.DispatchShell(trip.getId(), trip.getTripNo(), warehouse.id(), warehouse.code(), + warehouse.name(), vehicle, driver, vehicleStatus); + } + + /** 批量草稿转换先统一锁车辆、司机和占用,后续新trip为事务内新身份且不可被其他会话引用。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDispatchCreationResources(List requestedVehicles,List requestedDrivers) { + var vehicleIds=new TreeSet();var driverIds=new TreeSet(); + for(var vehicle:requestedVehicles)if(vehicle!=null && "MASTER".equals(vehicle.mode())) { + if(vehicle.vehicleId()==null || vehicle.vehicleId()<=0)throw bad("请选择真实车辆档案");vehicleIds.add(vehicle.vehicleId()); + } + for(var driver:requestedDrivers)if(driver!=null && "MASTER".equals(driver.mode())) { + if(driver.driverId()==null || driver.driverId()<=0)throw bad("请选择真实司机档案");driverIds.add(driver.driverId()); + } + for(var id:vehicleIds)if(vehicles.lockById(id).isEmpty())throw bad("车辆档案不存在"); + for(var id:driverIds)if(repository.lockDriverIdentity(id)==null)throw bad("司机档案不存在"); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + } + + /** 主单插入后再建立关系,允许数据库同时约束trip和dispatch两个外键。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void linkDispatchShell(String operator,long dispatchId,String dispatchNo,String tripNo) { + var trip=repository.lockTrip(tripNo); + if(trip==null || !"DELIVERY".equals(trip.getTripPurpose()) || !repository.listBindings(trip.getId()).isEmpty()) + throw conflict("整车资源已关联其他主单"); + if(repository.insertWholeDispatch(dispatchId,dispatchNo,trip,operator)!=1) throw conflict("整车唯一资源关系未保存"); + } + + /** 新主流程先锁trip、车辆、司机身份,再由调用方锁dispatch。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo) { + return lockDispatchShell(dispatchNo,null,null); + } + + /** 原预约跨车改量先收齐所有trip,再收齐全部车辆、司机和占用,尚不锁dispatch。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockExistingDispatchShells(List numbers) { + if(numbers==null || numbers.isEmpty() || numbers.size()>1000 || numbers.stream().anyMatch(n->n==null || n.isBlank()) || new HashSet<>(numbers).size()!=numbers.size())throw bad("请选择完整且不重复的原排车范围"); + var observed=new TreeMap(); + for(var number:numbers) { + var binding=repository.wholeBinding(number); + if(binding==null || observed.putIfAbsent(binding.getTripId(),binding)!=null)throw conflict("原排车资源范围缺失或非一对一关联"); + } + var locked=new ArrayList(); + for(var entry:observed.entrySet()) { + var trip=repository.lockTrip(entry.getValue().getTripNo()); + if(trip==null || !entry.getKey().equals(trip.getId()))throw conflict("原排车资源范围已变化"); + locked.add(trip); + } + for(var trip:locked) { + var bindings=repository.listBindings(trip.getId());var original=observed.get(trip.getId()); + if(bindings.size()!=1 || !Objects.equals(bindings.get(0).getDispatchId(),original.getDispatchId()) || !Objects.equals(bindings.get(0).getDispatchNo(),original.getDispatchNo()))throw conflict("原排车一对一关联已变化,请重读完整范围"); + } + var vehicleIds=new TreeSet();var driverIds=new TreeSet(); + for(var trip:locked){if(trip.getVehicleId()!=null)vehicleIds.add(trip.getVehicleId());if(trip.getDriverId()!=null)driverIds.add(trip.getDriverId());} + for(var id:vehicleIds)vehicles.lockById(id); + for(var id:driverIds)repository.lockDriverIdentity(id); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + } + + /** 只读定位本车资源身份,不取得写资格。 */ + @Transactional(readOnly=true) + public FleetExecutionModels.DispatchShellLock dispatchIdentity(String dispatchNo) { + var binding=repository.wholeBinding(dispatchNo);if(binding==null)throw conflict("整车资源关系缺失"); + var trip=repository.findTrip(binding.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 签收汇总只锁资源父记录身份,不变更或释放车辆司机,不申请其主档锁。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchCompletion(String no) { + var observed=repository.wholeBinding(no);if(observed==null)throw conflict("整车资源关系缺失"); + var trip=repository.lockTrip(observed.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + var bindings=repository.listBindings(trip.getId());if(bindings.size()!=1 || !no.equals(bindings.get(0).getDispatchNo()))throw conflict("整车资源关系已变化"); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 实载确认沿现有站序核对卸货位置,不能由客户端自由编造站点。 */ + @Transactional(readOnly=true) + public List dispatchStops(String no) { + var identity=dispatchIdentity(no);var trip=repository.findTrip(identity.tripNo()); + return read(trip.getStopsJson(),new TypeReference>(){}).stream() + .map(stop->new FleetExecutionModels.DispatchStop(stop.name(),stop.address(),stop.destinationKey())).toList(); + } + + /** 仅重排现有真实目的地,保留起点和外接预订;不接受客户端伪造名称或地址。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void reorderDispatchStops(String no,String operator,List destinationKeys,String reason) { + var identity=dispatchIdentity(no);var trip=repository.lockTrip(identity.tripNo()); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅未发车的路线可调整"); + var previous=read(trip.getStopsJson(),new TypeReference>(){}); + if(previous.isEmpty())throw conflict("本车缺少真实起运仓"); + var byKey=new LinkedHashMap(); + for(int index=1;index199 + || destinationKeys.stream().anyMatch(Objects::isNull) || new HashSet<>(destinationKeys).size()!=destinationKeys.size() + || !new HashSet<>(destinationKeys).equals(byKey.keySet()))throw bad("请完整重排本车既有目的地,保留外接卸货点"); + var stops=new ArrayList();stops.add(previous.get(0));destinationKeys.forEach(key->stops.add(byKey.get(key))); + bookingReservations.requireRoute(trip,stops);long version=trip.getRecordVersion(); + if(repository.updateTrip(null,new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version) + .set(FleetTripEntity::getStopsJson,write(stops)).set(FleetTripEntity::getPlannedLoadJson,null).set(FleetTripEntity::getLoadJson,null) + .set(FleetTripEntity::getLoadingReviewJson,null).set(FleetTripEntity::getRecordVersion,version+1).set(FleetTripEntity::getUpdatedBy,operator).set(FleetTripEntity::getUpdatedAt,now()))!=1)throw conflict("资源路线版本已变化"); + record(repository.findTrip(identity.tripNo()),"DISPATCH_ROUTE",reason,operator); + } + + /** 主单零扫码取消时协调本车资源,外接已确认业务沿原取消前置保留。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void cancelDispatchShell(String no,String operator,String reason) { + var identity=dispatchIdentity(no);var trip=repository.lockTrip(identity.tripNo()); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("资源任务已开始执行,不能取消"); + if(bookingReservations.hasConfirmed(trip.getId()))throw conflict("请先取消已确认的外接预订,再取消排车"); + if(trip.getVehicleId()!=null)release("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null)release("DRIVER",trip.getDriverId(),trip.getId()); + trip.setStatus("CANCELLED");persist(trip,trip.getRecordVersion(),"DISPATCH_CANCEL",reason,operator); + } + + /** 编辑资源时先锁齐旧新车辆,再锁齐旧新司机,禁止持司机锁回头抢新车辆。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo,Long vehicleId,Long driverId) { + var observed = repository.wholeBinding(dispatchNo); + if (observed == null) throw conflict("整车资源关联缺失,请核对原排车单"); + var trip = repository.lockTrip(observed.getTripNo()); + if (trip == null) throw conflict("整车资源记录缺失"); + var bindings = repository.listBindings(trip.getId()); + if (bindings.size() != 1 || !dispatchNo.equals(bindings.get(0).getDispatchNo())) throw conflict("整车资源关联已变化"); + var vehicleIds=new TreeSet(); if(trip.getVehicleId()!=null)vehicleIds.add(trip.getVehicleId());if(vehicleId!=null)vehicleIds.add(vehicleId); + for(var id:vehicleIds) vehicles.lockById(id); + var driverIds=new TreeSet();if(trip.getDriverId()!=null)driverIds.add(trip.getDriverId());if(driverId!=null)driverIds.add(driverId); + for(var id:driverIds) repository.lockDriverIdentity(id); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 已持父级全部身份锁后,复验资源实际资格;快照不信任请求中的主档名称和容量。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell dispatchResources(String dispatchNo,FleetExecutionModels.DispatchVehicle vehicle, + FleetExecutionModels.DispatchDriver driver) { + var binding=repository.wholeBinding(dispatchNo);if(binding==null)throw conflict("整车资源关系缺失"); + var trip=repository.lockTrip(binding.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + String vehicleStatus=null; + if(vehicle!=null && "MASTER".equals(vehicle.mode())) { + if(vehicle.vehicleId()==null)throw bad("请选择车辆档案"); + var actual=vehicles.findById(vehicle.vehicleId()).orElseThrow(()->bad("车辆档案不存在")); + if(!"OVERSEAS".equals(actual.region()) || !"IN_SERVICE".equals(actual.status()))throw conflict("车辆当前不可执行海外任务"); + var assignment=repository.resource("VEHICLE",actual.id()); + if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("车辆已被其他车次占用"); + vehicle=new FleetExecutionModels.DispatchVehicle("MASTER",actual.id(),actual.plateNumber(),actual.driverOrDispatchCompany(), + actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),List.of());vehicleStatus=actual.status(); + } else if(vehicle!=null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId()!=null)) throw bad("临时车辆不得关联车辆档案"); + if(driver!=null && "MASTER".equals(driver.mode())) { + if(driver.driverId()==null)throw bad("请选择司机档案"); + var actual=drivers.requireDispatchable(driver.driverId(),driver.expectedTaskEndAt()); + if(!"OVERSEAS".equals(actual.details().region()))throw conflict("司机当前不可执行海外任务"); + var assignment=repository.resource("DRIVER",driver.driverId()); + if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("司机已被其他车次占用"); + driver=new FleetExecutionModels.DispatchDriver("MASTER",Long.valueOf(actual.id()),actual.details().name(),actual.details().phone(), + actual.details().licenseNumber(),actual.details().licenseExpiresOn(),actual.licenseFileId()==null?List.of():List.of(Long.valueOf(actual.licenseFileId())),driver.expectedTaskEndAt()); + } else if(driver!=null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId()!=null))throw bad("临时司机不得关联司机档案"); + return new FleetExecutionModels.DispatchShell(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString(),trip.getWarehouseCode(),trip.getWarehouseName(),vehicle,driver,vehicleStatus); + } + + /** 已核对来源的更新原子替换资源占用,保留本趟附加能力和旧资源历史。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void saveDispatchResources(String dispatchNo,String operator,FleetExecutionModels.DispatchShell actual,Instant plannedAt,String remark) { + var trip=repository.lockTrip(actual.tripNo());if(trip==null || !"PLANNED".equals(trip.getStatus()))throw conflict("本车资源任务已开始执行"); + var v=actual.vehicle();var d=actual.driver();Long vehicleId=v==null?null:v.vehicleId(),driverId=d==null?null:d.driverId(); + if(trip.getVehicleId()!=null && !Objects.equals(trip.getVehicleId(),vehicleId))release("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null && !Objects.equals(trip.getDriverId(),driverId))release("DRIVER",trip.getDriverId(),trip.getId()); + if(vehicleId!=null)reserve("VEHICLE",vehicleId,trip);if(driverId!=null)reserve("DRIVER",driverId,trip); + long version=trip.getRecordVersion(); + var update=new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version) + .set(FleetTripEntity::getVehicleId,vehicleId).set(FleetTripEntity::getVehiclePlate,v==null?null:v.plate()) + .set(FleetTripEntity::getMaxPalletSpaces,v==null?null:v.palletSpaces()).set(FleetTripEntity::getMaxLoadKg,v==null?null:v.maxLoadKg()) + .set(FleetTripEntity::getMaxVolumeCbm,v==null?null:v.maxVolumeCbm()).set(FleetTripEntity::getDriverId,driverId) + .set(FleetTripEntity::getDriverName,d==null?null:d.name()).set(FleetTripEntity::getDriverPhone,d==null?null:d.phone()) + .set(FleetTripEntity::getExpectedDriverEndAt,d==null||d.expectedTaskEndAt()==null?null:utc(d.expectedTaskEndAt())) + .set(FleetTripEntity::getPlannedDepartureAt,plannedAt==null?null:utc(plannedAt)).set(FleetTripEntity::getRemark,remark) + .set(FleetTripEntity::getPlannedLoadJson,null).set(FleetTripEntity::getLoadJson,null).set(FleetTripEntity::getLoadingReviewJson,null) + .set(FleetTripEntity::getRecordVersion,version+1).set(FleetTripEntity::getUpdatedAt,now()).set(FleetTripEntity::getUpdatedBy,operator); + if(repository.updateTrip(null,update)!=1)throw conflict("整车资源版本已变化"); + record(repository.findTrip(actual.tripNo()),"DISPATCH_EDIT",remark,operator); + } + + /** 父锁内同步真实货量和目的地;已确认外接站点顺序不得重排。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void updateDispatchCargo(String dispatchNo, int packages, List destinations) { + var binding = repository.wholeBinding(dispatchNo); + if (binding == null) throw conflict("整车关联缺失"); + var trip = repository.lockTrip(binding.getTripNo()); + if (trip == null || !"PLANNED".equals(trip.getStatus())) throw conflict("仅未发车整车可以调整货物"); + List existing = read(trip.getStopsJson(),new TypeReference>() { }); + String originKey="ORIGIN:"+trip.getWarehouseId(); + var origin=existing.isEmpty()?new FleetTripModels.Stop(trip.getWarehouseName(),null,originKey):existing.get(0); + if(!originKey.equals(origin.destinationKey()))throw conflict("本车缺少与起运仓一致的路线起点"); + var requested=new LinkedHashMap(); + for(var stop:destinations) { + if(stop==null || stop.destinationKey()==null || stop.destinationKey().isBlank() || originKey.equals(stop.destinationKey()))throw conflict("实际货物缺少有效卸货目的地"); + requested.putIfAbsent(stop.destinationKey(),stop); + } + var supported=new HashSet<>(bookingReservations.requiredRouteDestinations(trip.getId())); + supported.addAll(requested.keySet()); + var stops=new ArrayList();stops.add(origin); + var keys=new HashSet();keys.add(originKey); + // 仅删除失去货物和有效预订支持的站;保留其余站的原相对顺序与事实快照。 + for(var stop:existing.subList(Math.min(1,existing.size()),existing.size())) + if(supported.contains(stop.destinationKey()) && keys.add(stop.destinationKey()))stops.add(stop); + for(var stop:requested.values()) if(keys.add(stop.destinationKey()))stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey())); + // 不能为缺失的外接站编造名称/地址;异常承诺必须阻断整笔主单事务。 + bookingReservations.requireRoute(trip,stops); + long version=trip.getRecordVersion(); + trip.setStopsJson(write(stops)); trip.setPlannedLoadJson(null); trip.setLoadJson(null); trip.setLoadingReviewJson(null); + trip.setRecordVersion(Math.addExact(version,1)); trip.setUpdatedAt(now()); + if (repository.updateTrip(trip,new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version)) != 1 + || repository.wholeQuantity(dispatchNo,packages) != 1) throw conflict("整车货物快照保存失败"); + } + /** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */ private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) { String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request)); @@ -136,6 +405,7 @@ public class FleetTripService { @Transactional public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); + if(!repository.listBindings(trip.getId()).isEmpty())throw conflict("本车资源请通过排车主单调整并重新审核"); requireReason(request.reason()); // 分配始终先锁车辆再锁司机,唯一占用记录作为并发分配的最终约束。 var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); @@ -161,21 +431,17 @@ public class FleetTripService { trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber()); trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone()); - for (var binding : repository.listBindings(trip.getId())) { - var dispatch = dispatches.lockForAssignment(binding.getDispatchNo()); - if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致"); - dispatches.assign(dispatch, assignment(trip)); - } persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator); var currentBindings=bindingResponses(trip); var history=previousDispatches(trip,currentBindings); - return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason()); + return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, loadingReview(trip),history.dispatches(),history.unavailableReason()); } /** 修改未发车计划;取货车次保留原单取货点和目的仓。 */ @Transactional public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); + if(!repository.listBindings(trip.getId()).isEmpty())throw conflict("本车计划请通过排车主单调整并重新审核"); requireReason(request.reason()); validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); bookingReservations.requireRoute(trip, request.stops()); @@ -184,37 +450,13 @@ public class FleetTripService { if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线"); boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt())) && trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt())); - for (var binding : repository.listBindings(trip.getId())) { - if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo()); - else dispatches.lockForPlanning(binding.getDispatchNo()); - } trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark())); persist(trip, request.recordVersion(), unchangedPlan ? "REMARK" : "PLAN", request.reason(), operator); return detail(trip); } - /** 逐张审核客户排车单,不以同车其他客户审核代替本单。 */ - @Transactional - public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) { - var trip = planned(tripNo, request.recordVersion()); - String reason = FleetTripReview.reason(request); - if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); - if ("APPROVED".equals(request.decision())) { - var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> bad("车辆档案不存在")); - if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) throw conflict("车辆当前不可派车"); - var driver = drivers.requireDispatchable(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())); - if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机"); - } - var dispatch = dispatches.lockForAssignment(dispatchNo); - var binding = repository.lockBinding(dispatch.dispatchId()); - if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); - binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion()))); - if (repository.updateBinding(binding) != 1) throw conflict("排车单审核保存失败"); - trip.setLoadingReviewJson(null); - persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator); - return detail(trip); - } + /** 取消未执行车次,释放资源并将取货原单退回待安排。 */ @Transactional @@ -238,43 +480,9 @@ public class FleetTripService { return detail(trip); } - @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; - /** 读取当前车次可关联的客户排车候选。 */ - @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) - public com.baiyue.common.core.api.PageResult dispatchCandidates(String tripNo,String keyword,int page,int size) { - var trip=repository.findTrip(tripNo); - if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在"); - if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物"); - return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size); - } - /** 关联同仓客户排车单并同步车辆司机,禁止取货车次混用。 */ - @Transactional - public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { - var trip = planned(tripNo, request.recordVersion()); - if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次不能关联出库排车单"); - requireReason(request.reason()); - if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); - var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); - appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId()); - if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓"); - var bound = repository.lockBinding(dispatch.dispatchId()); - if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo()); - var binding = new FleetTripDispatch(); - binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId()); - binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount()); - binding.setBoundBy(operator); binding.setBoundAt(now()); - try { if (repository.insertBinding(binding) != 1) throw conflict("排车单关联失败"); } - catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); } - bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator); - var route = matchedStops(response(trip).stops(), dispatches.manifest(dispatch.dispatchNo(), false).cargo()); - validatePlan(instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()), route); - bookingReservations.requireRoute(trip, route); - trip.setStopsJson(write(route)); - dispatches.assign(dispatch, assignment(trip)); - persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator); - return detail(trip); - } + + static List matchedStops(List existing, List cargo) { // Keep established stop indexes/order: confirmed bookings may refer to them. @@ -297,20 +505,7 @@ public class FleetTripService { return List.copyOf(result); } - /** 解除尚未执行的排车关联并使原装载审核失效。 */ - @Transactional - public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { - var trip = planned(tripNo, request.recordVersion()); - requireReason(request.reason()); - var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); - var binding = repository.lockBinding(dispatch.dispatchId()); - if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); - dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null)); - if (repository.deleteBinding(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试"); - bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator); - persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator); - return detail(trip); - } + /** 分页读取不可变车次历史,用于追溯计划和执行变更。 */ public PageResult history(String tripNo, int page, int size) { @@ -362,14 +557,25 @@ public class FleetTripService { var currentBindings=bindingResponses(trip); var history=previousDispatches(trip,currentBindings); return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null - : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason()); + : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, loadingReview(trip),history.dispatches(),history.unavailableReason()); } - /** 将客户隔离的排车关联转换为详情展示模型。 */ + /** Fleet只展示主单当前审核事实;没有主单的取货记录没有排车审核。 */ + private FleetTripModels.ReviewState loadingReview(FleetTripEntity trip) { + var members=repository.listBindings(trip.getId()); + if(members.isEmpty())return new FleetTripModels.ReviewState("NOT_SUBMITTED",null,null,null,null); + if(members.size()!=1)throw conflict("车次必须唯一关联整车主单"); + var fact=dispatches.workflows(List.of(members.get(0).getDispatchNo())).get(members.get(0).getDispatchNo()); + if(fact==null)throw conflict("资源关联缺少真实整车审核事实"); + return fact.loadingReview(); + } + /** 将整车关联和逐客户预约引用转换为详情展示模型。 */ private List bindingResponses(FleetTripEntity trip) { var current=repository.listBindings(trip.getId()); var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList()); + var facts=dispatches.workflows(current.stream().map(FleetTripDispatch::getDispatchNo).toList()); + if(current.stream().anyMatch(b->!facts.containsKey(b.getDispatchNo())))throw conflict("资源关联缺少真实整车审核事实"); return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(), - binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList(); + binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), facts.get(binding.getDispatchNo()).dispatchReview(),references.getOrDefault(binding.getDispatchId(),List.of()))).toList(); } /** 组装当前车次关联单据和预约追溯信息。 */ private record AppointmentHistory(List dispatches,String unavailableReason) { } @@ -389,9 +595,9 @@ public class FleetTripService { var references=new ArrayList(); for(var batch:binding.appointmentBatches()) { var missing=batch.sources().stream().filter(source->seen.add(referenceKey(batch,source))).toList(); - if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.customerId(),missing)); + if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.recordVersion(),batch.customerId(),missing)); } - if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),binding.customerId(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references))); + if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references))); } } return new AppointmentHistory(List.copyOf(previous),unavailableReason); @@ -430,8 +636,6 @@ public class FleetTripService { if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null); if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null); if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null); - if (Set.of("ASSIGN", "PLAN").contains(action)) repository.updateBindings(new LambdaUpdateWrapper() - .eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null)); trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator); if (repository.updateTrip(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1) throw conflict("车次已被修改,请刷新后重试"); @@ -444,7 +648,7 @@ public class FleetTripService { event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt()); event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null - : read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()), + : read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), loadingReview(trip), trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId())))); if (repository.insertHistory(event) != 1) throw conflict("车次历史保存失败"); } @@ -463,6 +667,8 @@ public class FleetTripService { } /** 按指定模型解析历史 JSON,损坏记录明确报错。 */ private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } } + /** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */ + private T read(String value, TypeReference type) { try { return json.readValue(value,type); } catch (JsonProcessingException error) { throw new IllegalStateException("车次集合事实读取失败",error); } } /** 为幂等请求生成不可逆内容摘要。 */ private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } /** 使用统一时钟获取 UTC 业务时间。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java index d7fbcd061..a54777c1d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java @@ -51,7 +51,7 @@ public class PlatformFleetCapacityOfferController { public ApiResponse incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));} @PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')") public ApiResponse receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));} - @PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')") + @PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:dispatch:create')") public ApiResponse arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));} @PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java index 21cec563c..ff93ee439 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -27,13 +27,7 @@ public class PlatformFleetTripController { throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历"); return ApiResponse.success(calendar.calendar(from,to)); } - @jakarta.annotation.Resource private FleetTripPreparationService preparation; - @PostMapping("/prepare") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody FleetTripPreparationService.Prepare request) { - return ApiResponse.success(preparation.prepare(operator(user),request)); - } + private final FleetTripService service; private final FleetTripExecutionService execution; private final FleetTripCompletionService completion; @@ -47,11 +41,7 @@ public class PlatformFleetTripController { @GetMapping("/{tripNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") public ApiResponse detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); } - @PostMapping - @PreAuthorize("hasAuthority('platform:fleet:trip:create')") - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetTripModels.Create request) { - return ApiResponse.success(service.create(operator(user), request)); - } + @PutMapping("/{tripNo}/plan") @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") public ApiResponse plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) { @@ -78,43 +68,17 @@ public class PlatformFleetTripController { public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) { return ApiResponse.success(service.cancel(tripNo, operator(user), request)); } - @GetMapping("/{tripNo}/dispatch-candidates") - @PreAuthorize("hasAuthority('platform:fleet:trip:list')") - public ApiResponse> dispatchCandidates(@PathVariable String tripNo, - @RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page, - @RequestParam(defaultValue="20") @Min(1) @Max(100) int size) { - return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size)); - } - @PostMapping("/{tripNo}/dispatches/bind") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @Valid @RequestBody FleetTripModels.BindingCommand request) { - return ApiResponse.success(service.bindDispatch(tripNo, operator(user), request)); - } - @PostMapping("/{tripNo}/dispatches/unbind") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse unbind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @Valid @RequestBody FleetTripModels.BindingCommand request) { - return ApiResponse.success(service.unbindDispatch(tripNo, operator(user), request)); - } + + + @GetMapping("/{tripNo}/history") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") public ApiResponse> history(@PathVariable String tripNo, @RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) { return ApiResponse.success(service.history(tripNo, page, size)); } - @PostMapping("/{tripNo}/dispatches/{dispatchNo}/review") - @PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')") - public ApiResponse reviewDispatch(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @PathVariable String dispatchNo, @Valid @RequestBody FleetTripModels.Review request) { - return ApiResponse.success(service.reviewDispatch(tripNo, dispatchNo, operator(user), request)); - } - @PostMapping("/{tripNo}/loading-review") - @PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')") - public ApiResponse reviewLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @Valid @RequestBody FleetTripModels.Review request) { - return ApiResponse.success(execution.reviewLoading(tripNo, operator(user), request)); - } + + @GetMapping("/{tripNo}/loading") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") public ApiResponse loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); } @@ -127,12 +91,7 @@ public class PlatformFleetTripController { @Valid @RequestBody FleetExecutionModels.Confirm request) { return ApiResponse.success(execution.confirmCapacityPlan(tripNo, operator(user), request)); } - @PostMapping("/{tripNo}/loading") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse confirmLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @Valid @RequestBody FleetExecutionModels.Confirm request) { - return ApiResponse.success(execution.confirmLoad(tripNo, operator(user), request)); - } + private static String operator(AuthenticatedUser user) { if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); return user.userId().toString(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java index 720cd2b0f..eac3a86e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java @@ -15,7 +15,7 @@ public interface VehicleFulfillmentMapper { + "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)"; String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")"; String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b " - + "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) " + + "ON b.dispatch_id=d.dispatch_order_id AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=b.dispatch_id AND CAST(i.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128))) " + "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")"; String PREVIOUS_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.vehicleId'))"; String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))"; 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 index 216503c41..49bdc2429 100644 --- 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 @@ -44,7 +44,8 @@ public interface InboundFbaAppointmentQueryMapper { @Select(""" SELECT COUNT(DISTINCT d.id) FROM ll_dispatch_appointment_allocation a - JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id + JOIN ll_dispatch_order d ON d.id=a.dispatch_id + JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id AND i.plan_pallet_id=a.plan_pallet_id WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',#{appointmentId}) AND d.status<>'CANCELLED' """) long countActiveDispatches(@Param("appointmentId") Long appointmentId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index 2e24d2207..38357c4bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -14,7 +14,6 @@ public interface InboundPlanQueryMapper { String APPOINTMENT_STATUS="CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN 'MULTIPLE' ELSE a.appointment_status END"; String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" + " JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" + - " AND dispatch.customer_id=di.customer_id" + " WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" + " AND dispatch.status<>'CANCELLED')"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java index 9bf6eae1e..17fd19e7d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java @@ -9,5 +9,5 @@ public interface FbaAppointmentExecutionPort { record Scope(String version,List members) { } Scope inspect(List members); /** Locks original trips and selected customers' dispatch/execution rows before core batch locks. */ - Scope lockAndInspect(List members,String expectedVersion); + Scope lockAndInspect(List members,String expectedVersion,String operator); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java index 17bff899f..9ec4742de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java @@ -11,8 +11,8 @@ public interface FbaAppointmentTransportPort { int originalPackages, int retainedPackages, String laterBatchKey) { } record Result(List vehicles, List nextActions) { } Scope inspect(List batchKeys); - /** Lock order: trip -> dispatch -> outbound/PDA -> appointment; called BEFORE core locks batches. */ - Scope lockAndInspect(List batchKeys, String expectedVersion); + /** 锁全范围trip→车辆/司机资源→dispatch;必须先于核心批次与来源锁。 */ + Scope lockAndInspect(List batchKeys, String expectedVersion,String operator); /** Same transaction as core allocation. Throw on failure so physical/allocation changes roll back together. */ - Result apply(Scope locked, List moves, String operator, String reason); + Result apply(Scope locked, List moves, String operator, String reason,String parentCommandKey); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java index 0edb7b049..5a07887fc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java @@ -72,7 +72,7 @@ public class FbaReceivingService { public Result propose(String operator,Propose request) { return command("RECEIVING_PROPOSE",operator,request.idempotencyKey(),request,()->{ var intent=request.intent();var followup=work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate()); - var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion());requireMutable(scope); + var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion(),operator);requireMutable(scope); var batch=batches.lockReceivingBatch(intent.batchKey());requireVersion(batch,intent.batchVersion()); recheckTransport(List.of(intent.batchKey()),scope); if(store.hasPending(intent.batchKey(),intent.customerId())) throw conflict("本客户已有待确认变化,请继续原记录"); @@ -86,6 +86,7 @@ public class FbaReceivingService { return new Result(pending.changeKey(),"WAITING",List.of(batch),pending.originalAgreements(),List.of(new Step("RECORD_CHANGE","SUCCESS",pending.changeKey(),"原安排及数量保留,待确认变化已阻止相关发车")),List.of("CONTINUE_WAREHOUSE_CONFIRMATION")); }); } + /** 原批次、运输、收货组和跟进在同一父命令事务内处理,跟进guard最后进入。 */ public Result resolve(String operator,String changeKey,Resolve request) { return command("RECEIVING_RESOLVE:"+changeKey,operator,request.idempotencyKey(),request,()->{ var initial=store.change(changeKey,false);var intent=initial.intent(); @@ -93,7 +94,7 @@ public class FbaReceivingService { // picking starts, without editing cargo, the appointment, or vehicle facts. var withdrawing="WITHDRAW".equals(request.decision()); var followup=withdrawing?null:work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate()); - var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion()); + var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion(),operator); if(!withdrawing) requireMutable(scope); var batch=batches.lockReceivingBatch(intent.batchKey());var pending=store.change(changeKey,true); if(pending.version()!=request.version() || !"WAITING".equals(pending.status())) throw conflict("变化已被处理,请重新读取当前结果"); @@ -112,13 +113,12 @@ public class FbaReceivingService { var currentKey=store.currentAgreement(intent.batchKey(),intent.customerId()); if(!Objects.equals(currentKey==null?null:currentKey.agreementKey(),original==null?null:original.agreementKey())) throw conflict("本客户有效预约关系已变化"); var changed=batches.moveReceivingCustomer(operator,intent);var retained=changed.get(0);var later=changed.get(1); - work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator); var movements=new ArrayList(); for(var source:pending.originalMembers().get(0).sources()) { var q=intent.quantities().stream().filter(v->v.sourceKey().equals(source.sourceKey()) && v.destinationKey().equals(source.destinationKey())).findFirst().orElseThrow(); movements.add(new FbaAppointmentTransportPort.Move(intent.batchKey(),intent.customerId(),source.sourceKey(),source.destinationKey(),source.packageCount(),q.retainedPackages(),later.batchKey())); } - var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason()); + var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason(),request.idempotencyKey()); var agreements=new ArrayList(); if(original!=null) { var members=new ArrayList<>(original.members());members.removeIf(m->m.batchKey().equals(intent.batchKey()) && m.customerId().equals(intent.customerId())); @@ -130,13 +130,16 @@ public class FbaReceivingService { var steps=List.of(new Step("ALLOCATE_CARGO","SUCCESS",later.batchKey(),"原来源总量不变,后送量已落在新批次"),new Step("ADJUST_TRANSPORT","SUCCESS",intent.batchKey(),"原车辆按实际保留货物调整")); var actions=new ArrayList();actions.add("BOOK_LATER_BATCH");actions.addAll(physical.nextActions()); var result=new Result(changeKey,"APPLIED",List.of(projected,later),List.copyOf(agreements),steps,List.copyOf(actions)); - store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches());return result; + store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches()); + // 原运输、批次、来源和收货组全部写完后才获取跟进guard。 + work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator); + return result; }); } public Result refreshCargo(String operator,RefreshCargo request) { return command("RECEIVING_REFRESH_CARGO",operator,request.idempotencyKey(),request,()->{ var selection=request.member();var selected=List.of(new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId())); - var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked); + var locked=execution.lockAndInspect(selected,request.executionVersion(),operator);requireRebookable(selected,locked); var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion()); var current=execution.inspect(selected); if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); @@ -151,7 +154,7 @@ public class FbaReceivingService { public Result plan(String operator,Plan request) { return command("RECEIVING_PLAN",operator,request.idempotencyKey(),request,()->{ var selection=request.member();var memberKey=new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId()); - var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked); + var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion(),operator);requireRebookable(selected,locked); var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion()); var current=execution.inspect(selected); if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); @@ -173,7 +176,7 @@ public class FbaReceivingService { var selections=request.bookings().stream().flatMap(b->b.members().stream()).toList(); var keys=selections.stream().map(Selection::batchKey).distinct().sorted().toList(); var executionMembers=selections.stream().map(s->new FbaAppointmentExecutionPort.Member(s.batchKey(),s.customerId())).distinct().toList(); - var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion()); + var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion(),operator); requireRebookable(executionMembers,executionScope); var snapshots=lock(keys); // An unbound customer can acquire a new dispatch before we obtain the batch lock. @@ -209,7 +212,7 @@ public class FbaReceivingService { public Result changeGroup(String operator,String key,GroupChange request) { return command("RECEIVING_GROUP:"+key,operator,request.idempotencyKey(),request,()->{ var initial=store.agreement(key,false);var keys=initial.members().stream().map(Member::batchKey).distinct().sorted().toList(); - var scope=transport.lockAndInspect(keys,request.transportVersion());requireMutable(scope);var snapshots=lock(keys); + var scope=transport.lockAndInspect(keys,request.transportVersion(),operator);requireMutable(scope);var snapshots=lock(keys); recheckTransport(keys,scope); var group=store.agreement(key,true);requireActive(group); if(group.version()!=request.version()) throw conflict("共同预约范围已变化,请重新核对全部客户"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java index 5bc4fbd0a..86288be8b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.appointmentwork.*; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.*; +import org.springframework.transaction.support.*; import java.util.*; import java.util.stream.Collectors; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -18,16 +19,16 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(dispatchIds.stream().anyMatch(id->id==null || id<=0)) throw bad("排车引用缺少真实排车身份"); var rows=mapper.references(dispatchIds.stream().distinct().sorted().toList()); if(rows.size()>20000) throw unavailable("排车批次引用超过完整读取上限"); - record Group(long dispatchId,String batchKey,String batchNo,String customerId) { } + record Group(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId) { } var groups=new LinkedHashMap,Integer>>(); for(var row:rows) { - if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0) + if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.recordVersion()==null || row.recordVersion()<0 || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0) throw unavailable("原排车逐票批次引用不完整"); - groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.customerId()),ignored->new LinkedHashMap<>()) + groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.recordVersion(),row.customerId()),ignored->new LinkedHashMap<>()) .merge(List.of(row.sourceKey(),row.destinationKey()),row.packages(),Math::addExact); } var result=new LinkedHashMap>();dispatchIds.forEach(id->result.put(id,new ArrayList<>())); - groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.customerId(), + groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.recordVersion(),group.customerId(), sources.entrySet().stream().map(e->new BatchSource(e.getKey().get(0),e.getKey().get(1),e.getValue())).toList()))); result.replaceAll((id,refs)->List.copyOf(refs));return Collections.unmodifiableMap(result); } @@ -36,53 +37,95 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo private final AppointmentSourceService sources; private final AppointmentWorkChanges changes; private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; + private final AppointmentWorkDispatchCandidates candidates; + private final Object prelockedBatchesKey=new Object(); + private final Object pendingInvalidationKey=new Object(); + /** 注入原预约、真实实物和收货确认权威。 */ public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes, - com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving) { - this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving; + com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving,AppointmentWorkDispatchCandidates candidates) { + this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving;this.candidates=candidates; } - public void bind(String customer,long dispatch,List items,List allocations) { - if(allocations==null) throw bad("批次实物分配清单必填"); - if(allocations.isEmpty()) { - if(mapper.byDispatch(dispatch).stream().anyMatch(a->a.batchKey().startsWith("FBA_APPOINTMENT:"))) throw conflict("已关联预约批次,修改实物时须同时提交完整批次分配"); - for(var item:items) if(item.planPalletId()!=null) requireNoBatch(customer,item.planPalletId()); - var privateRows=new ArrayList(); - for(var item:items) if(item.planPalletId()!=null) privateRows.addAll(privateAllocation(customer,dispatch,item)); - if(!privateRows.isEmpty() || mapper.count(dispatch)>0) { mapper.remove(dispatch);privateRows.forEach(mapper::insert);changes.requirementsChanged(); } - return; + /** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */ + @Transactional(readOnly=true,propagation=Propagation.SUPPORTS) + public Map candidateBatches(List input) {return candidates.batchReferences(input);} + /** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */ + public void lockBatches(Map expectedBatchVersions) { + if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效"); + var expected=new TreeMap(); + for(var entry:expectedBatchVersions.entrySet()) { + batchId(entry.getKey()); + if(entry.getValue()==null || entry.getValue()<0) throw bad("预约批次版本必填且不能小于零"); + expected.put(entry.getKey(),entry.getValue()); } - if(allocations.size()>1000) throw bad("批次实物分配最多1000条"); - var byPallet=items.stream().filter(i->i.planPalletId()!=null).collect(Collectors.toMap(PhysicalItem::planPalletId,i->i)); - var seen=new HashSet(); - for(var row:allocations) { - if(row==null || row.packageCount()<=0 || !customer.equals(row.customerId()) || !byPallet.containsKey(row.planPalletId())) throw conflict("批次分配必须属于本客户已选择的实物托盘"); - if(!seen.add(row.planPalletId()+"/"+row.batchKey()+"/"+row.sourceKey()+"/"+row.destinationKey())) throw bad("批次实物分配重复"); + @SuppressWarnings("unchecked") var locked=(Set)TransactionSynchronizationManager.getResource(prelockedBatchesKey); + if(locked!=null && !locked.containsAll(expected.keySet())) throw conflict("预约批次须在来源锁前一次锁齐"); + for(var entry:expected.entrySet()) { + var row=mapper.lockAppointment(batchId(entry.getKey())); + if(row==null || row.getVersion()==null || row.getVersion().longValue()!=entry.getValue()) throw conflict("预约批次版本已变化,请重新读取实物候选"); } - var batches=new TreeSet<>(allocations.stream().map(Allocation::batchKey).toList()); - for(var batch:batches) lock(batch); - var persisted=new ArrayList(); - for(var item:byPallet.values()) { - var selected=allocations.stream().filter(a->item.planPalletId().equals(a.planPalletId())).toList(); + if(locked==null && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.bindResource(prelockedBatchesKey,Set.copyOf(expected.keySet())); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务结束后释放本地预锁证明。 */ + public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(prelockedBatchesKey); } + }); + } + } + /** 逐项复核真实明细,只替换本次触及实物;完整有效集合参与同车及跨车余量核对。 */ + public void bindItems(long dispatch,List items,List allocations) { + if(dispatch<=0 || items==null || items.isEmpty() || items.size()>1000 || allocations==null || allocations.size()>1000) throw bad("批次实物分配范围无效"); + var actual=mapper.dispatchPhysicalById(dispatch).stream().collect(Collectors.toMap(AppointmentWorkDispatchMapper.DispatchPhysical::itemId,i->i)); + var byPallet=new HashMap,PhysicalItem>();var touched=new TreeSet(); + for(var item:items) { + if(item==null || item.itemId()==null || !touched.add(item.itemId())) throw bad("受影响实物身份缺失或重复"); + var row=actual.get(item.itemId()); + if(row==null || !Objects.equals(item.customerId(),row.customerId()) || !Objects.equals(item.planPalletId(),row.planPalletId()) + || !Objects.equals(item.sourceStage(),row.sourceStage()) || !Objects.equals(item.packages(),row.packages()) || row.packages()==null || row.packages()<=0) + throw conflict("受影响实物与本车真实客户、托盘或数量不一致"); + if(item.planPalletId()!=null && byPallet.put(List.of(item.customerId(),item.planPalletId().toString()),item)!=null) throw conflict("同客户托盘存在重复明细"); + } + var versions=new TreeMap();var seen=new HashSet>(); + for(var a:allocations) { + if(a==null || a.customerId()==null || a.planPalletId()==null || a.sourceKey()==null || a.destinationKey()==null || a.packageCount()<=0 + || !byPallet.containsKey(List.of(a.customerId(),a.planPalletId().toString()))) throw conflict("批次分配必须属于本次已核实的真实客户实物"); + batchId(a.batchKey()); + if(a.recordVersion()==null || a.recordVersion()<0) throw bad("预约批次版本必填且不能小于零"); + var previous=versions.putIfAbsent(a.batchKey(),a.recordVersion()); + if(previous!=null && !previous.equals(a.recordVersion())) throw conflict("同一预约批次不能提交不同版本"); + if(!seen.add(List.of(a.customerId(),a.planPalletId().toString(),a.batchKey(),a.sourceKey(),a.destinationKey()))) throw bad("批次实物分配重复"); + } + var previous=mapper.byDispatch(dispatch); + var effective=new ArrayList<>(previous.stream().filter(a->!touched.contains(a.itemId())).toList()); + var next=new ArrayList(); + for(var item:items) if(item.planPalletId()!=null) { + var selected=allocations.stream().filter(a->item.customerId().equals(a.customerId()) && item.planPalletId().equals(a.planPalletId())).toList(); if(selected.isEmpty()) { - var privateRows=privateAllocation(customer,dispatch,item); - if(privateRows.isEmpty()) throw conflict("批次排车须明确每个托盘的逐票实物分配"); - persisted.addAll(privateRows);continue; + requireNoBatch(item.customerId(),item.planPalletId()); + next.addAll(privateAllocation(item.customerId(),dispatch,item)); + } else { + validatePhysical(item.customerId(),item,selected,"RECEIVED".equals(item.sourceStage()),false); + for(var a:selected) next.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),item.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount())); } - validatePhysical(customer,item,selected,"RECEIVED".equals(item.sourceStage()),false); - for(var a:selected) persisted.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),customer,a.sourceKey(),a.destinationKey(),a.packageCount())); } + effective.addAll(next); + var batches=effective.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).collect(Collectors.toCollection(TreeSet::new)); + @SuppressWarnings("unchecked") var prelocked=(Set)TransactionSynchronizationManager.getResource(prelockedBatchesKey); + if(TransactionSynchronizationManager.isSynchronizationActive() && (prelocked==null || !prelocked.containsAll(batches))) throw conflict("预约批次未在来源锁前完成预锁"); for(var batch:batches) { - var context=appointments.batchContext(batch); var appointment=context.appointment(); - // Pre-arrangement reserves real cargo only. requireReceiving separately gates actual departure per customer. + var appointment=appointments.batchContext(batch).appointment(); + if(appointment.version()==null || appointment.version()<0 || versions.containsKey(batch) && appointment.version().longValue()!=versions.get(batch)) throw conflict("预约批次版本已变化,请重新读取实物候选"); if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次"); - requireCurrentAllocation(appointment,customer,dispatch,allocations.stream().filter(a->batch.equals(a.batchKey())).toList()); + for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) + requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())) + .map(a->allocation(a,appointment.version().longValue())).toList()); } - mapper.remove(dispatch); persisted.forEach(mapper::insert); changes.requirementsChanged(); + // 所有校验结束后才按明确item删除;没有整车清空操作。 + for(var item:touched) mapper.removeItem(dispatch,item); + next.forEach(mapper::insert);invalidateAtBusinessEnd(); } - public void requireReplay(String customer,long dispatch,List allocations) { - if(allocations==null || allocations.stream().anyMatch(a->a==null || !customer.equals(a.customerId()))) throw conflict("重放排车的逐票批次分配不一致"); - var original=mapper.byDispatch(dispatch).stream().filter(a->!a.batchKey().startsWith("DISPATCH:")) - .map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(); - if(allocations.size()!=original.size() || !new HashSet<>(allocations).equals(new HashSet<>(original))) throw conflict("原排车已存在,不能通过重放请求改写批次分配"); + /** 从原分配构造当前权威版本视图,不写入第三套版本账本。 */ + private Allocation allocation(AppointmentWorkDispatchMapper.Bound row,Long version) { + return new Allocation(row.palletId(),row.batchKey(),version,row.customerId(),row.sourceKey(),row.destinationKey(),row.packages()); } private void validatePhysical(String customer,PhysicalItem item,List selected,boolean received,boolean picked) { var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer); @@ -108,14 +151,16 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo } private void checkDeparture(String dispatchNo,boolean locked) { var rows=mapper.bindings(dispatchNo); - var linked=rows.stream().map(AppointmentWorkDispatchMapper.Bound::palletId).collect(Collectors.toSet()); - for(var item:mapper.dispatchPhysical(dispatchNo)) if(item.planPalletId()!=null && !linked.contains(item.planPalletId())) { + var physical=mapper.dispatchPhysical(dispatchNo); + for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId()))) + throw conflict("预约分配与本车真实客户实物不一致"); + var batches=rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).distinct().sorted().toList(); + if(locked) for(var batch:batches) lock(batch); + for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) { requireNoBatch(item.customerId(),item.planPalletId()); if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车"); } - for(var batch:rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { - if(batch.startsWith("DISPATCH:")) continue; - if(locked) lock(batch); + for(var batch:batches) { var a=appointments.batchContext(batch).appointment(); if(!a.replacementBatchKeys().isEmpty()) throw conflict("关联批次已被替代,请重新核对实物批次后发车"); var allocated=rows.stream().filter(row->batch.equals(row.batchKey())).toList(); @@ -124,13 +169,14 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo for(var customer:allocated.stream().map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().toList()) { requireReceiving(batch,customer,allocated.stream().filter(row->customer.equals(row.customerId())).toList()); requireCurrentAllocation(a,customer,dispatchIds.get(0),allocated.stream().filter(row->customer.equals(row.customerId())) - .map(row->new Allocation(row.palletId(),row.batchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages())).toList()); + .map(row->allocation(row,a.version()==null?null:a.version().longValue())).toList()); } } - for(var group:rows.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.Bound::palletId)).values()) { + for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) { var first=group.get(0); - validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.palletId(),"RECEIVED",null), - group.stream().map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(),true,true); + var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow(); + validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()), + group.stream().map(a->allocation(a,null)).toList(),true,true); } } private void requireReceiving(String batch,String customer,List rows) { @@ -168,7 +214,20 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty())) throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); } - public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId); changes.requirementsChanged(); } + /** 调用方已锁主单与原批次,删除只作用于指定实物。 */ + public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); } + /** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */ + private void invalidateAtBusinessEnd() { + if(!TransactionSynchronizationManager.isSynchronizationActive()) {changes.requirementsChanged();return;} + if(TransactionSynchronizationManager.hasResource(pendingInvalidationKey)) return; + TransactionSynchronizationManager.bindResource(pendingInvalidationKey,Boolean.TRUE); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 所有业务写入后获取guard;失败仍使整个原事务回滚。 */ + public void beforeCommit(boolean readOnly) {changes.requirementsChanged();} + /** 清除本事务本地标记,不复用于下一个请求。 */ + public void afterCompletion(int status) {TransactionSynchronizationManager.unbindResourceIfPossible(pendingInvalidationKey);} + }); + } public void requireTripBinding(String dispatchNo,long tripId) { for(var batch:mapper.bindings(dispatchNo).stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { if(batch.startsWith("DISPATCH:")) continue; @@ -190,8 +249,12 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo return rows; } private void lock(String batch) { - if(batch==null || !batch.startsWith("FBA_APPOINTMENT:")) throw bad("预约批次键无效"); - try { long id=Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); if(!batch.equals("FBA_APPOINTMENT:"+id) || mapper.lockAppointment(id)==null) throw conflict("预约批次不存在"); } - catch(NumberFormatException | IndexOutOfBoundsException error) { throw bad("预约批次键无效"); } + if(mapper.lockAppointment(batchId(batch))==null) throw conflict("预约批次不存在"); + } + /** 批次键只接受权威业务键,拒绝前导零、负数和溢出。 */ + private long batchId(String batch) { + if(batch==null || !batch.matches("FBA_APPOINTMENT:[1-9][0-9]*")) throw bad("预约批次键无效"); + try { return Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); } + catch(NumberFormatException error) { throw bad("预约批次键无效"); } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java index c0a15bd6d..46fbf5285 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.work; import com.baiyue.lastleg.appointmentplanning.*; import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,8 +16,62 @@ public class AppointmentWorkDispatchCandidates { private final AppointmentWorkDispatchMapper mapper; private final InboundFbaAppointmentService appointments; private final AppointmentSourceService sources; + /** 注入唯一原批次、实物和来源规范化边界。 */ public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; } + /** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */ @Transactional(readOnly=true) + public Map batchReferences(List input) { + if(input==null || input.size()>1000 || input.stream().anyMatch(i->i==null || i.customerId()==null || i.customerId().isBlank() || i.planPalletId()==null || i.planPalletId()<=0)) throw bad("候选批读须提供最多1000个真实客户托盘"); + var identities=input.stream().distinct().sorted(Comparator.comparing(CandidatePhysical::customerId).thenComparing(CandidatePhysical::planPalletId)).toList(); + if(identities.isEmpty()) return Map.of(); + var rows=mapper.candidateSources(identities); + if(rows.size()>20000) throw unavailable("候选逐票实物超过完整读取上限"); + var physical=rows.stream().filter(r->r.orderId()!=null && r.destinationKey()!=null) + .map(r->new AppointmentWorkDispatchMapper.Physical(r.orderId(),r.customerId(),r.destinationKey(),r.plannedPackages(),r.countedPackages())).toList(); + var canonical=canonical(physical); + var refs=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); + if(refs.size()>20000) throw unavailable("候选逐票来源超过完整读取上限"); + var orders=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).distinct().toList(); + var batches=refs.isEmpty()?List.of():mapper.candidateBatches(refs,orders); + if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限"); + var output=new LinkedHashMap(); + for(var identity:identities) { + var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList(); + var reasons=new LinkedHashSet();var references=new LinkedHashMap,List>(); + boolean related=false,unassigned=false;boolean occupied=selected.stream().anyMatch(AppointmentWorkDispatchMapper.CandidateSource::locked); + if(selected.isEmpty()) reasons.add("客户实物托盘不存在或归属已变化"); + for(var row:selected) { + var count="RECEIVED".equals(row.sourceStage())?row.countedPackages():row.plannedPackages(); + if(row.orderId()==null || row.destinationKey()==null || count==null || count<=0) {reasons.add("逐票实物来源或箱数尚未核实");continue;} + if(!row.destinationKey().startsWith("FBA:")) continue; + var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey(); + var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList(); + if(matched.isEmpty()) {unassigned=true;continue;} + related=true;long available=0;var seen=new HashSet(); + for(var b:matched) { + if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;} + if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;} + int remaining=Math.toIntExact(b.packages()-b.allocated());available=Math.addExact(available,remaining); + if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count))); + } + if(availablenew BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList(); + output.put(identity,new CandidateBatches(result,List.copyOf(reasons),occupied)); + } + return Collections.unmodifiableMap(output); + } + /** 保持原来源接口100票边界,按不同订单分块,重复目的地不增加远程调用。 */ + private Map canonical(List physical) { + var unique=new LinkedHashMap(); + for(var row:physical) unique.putIfAbsent(new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.orderId()),row); + var values=new ArrayList<>(unique.values());var result=new LinkedHashMap(); + for(int offset=0;offsetitem.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托"); - allocations.add(new Allocation(candidate.planPalletId(),batch,part.customerId(),ref.sourceKey(),part.destinationKey(),count)); + if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失"); + allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count)); } if(physical.isEmpty()) { packages=null; reasons.add("缺少逐票实物分配"); } output.add(new Candidate(candidate.planPalletId(),candidate.palletNo(),candidate.planId(),candidate.planNo(),candidate.customerId(),candidate.sourceStage(),packages,List.copyOf(allocations),reasons.stream().distinct().toList())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 72ade2a4f..3a43629fe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -10,17 +10,55 @@ public interface AppointmentWorkDispatchMapper { record Bound(long dispatchId,long itemId,long palletId,String batchKey,String customerId,String sourceKey,String destinationKey,int packages) { } record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,boolean locked) { } record DispatchPhysical(long itemId,Long planPalletId,String customerId,String sourceStage,Integer packages) { } - record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,Integer pickedPackages,Integer shippedPackages, + record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,String dispatchStage,Long dispatchVersion,Integer pickedPackages,Integer shippedPackages, java.time.LocalDateTime updatedAt,long itemId,long palletId,Integer itemPackages,String sourceStage, String batchKey,String sourceKey,String destinationKey,int packages,String tripNo,Long tripVersion,String tripStatus, Long outboundId,String outboundStatus,String taskStatus) { } record ReceivingSource(String batchKey,String customerId,String sourceKey,String destinationKey,Integer packages) { } - record ReferenceRow(long dispatchId,String batchKey,String batchNo,String customerId,String sourceKey,String destinationKey,int packages) { } + record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { } + record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } + record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { } + /** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */ + @Select(""" + + """) + List candidateSources(@Param("items") List items); + /** 原有效批次版本与跨车占用由同次查询提供,不逐候选读取batchContext。 */ + @Select(""" + + """) + List candidateBatches(@Param("refs") List refs,@Param("orders") List orders); @Select(""" ") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index d53b0c2fd..72d0d164f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -7,11 +7,105 @@ import com.baiyue.lastleg.palletops.model.PalletInventoryRow; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Options; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; import java.util.List; @Mapper public interface PdaPalletOperationMapper extends BaseMapper { + String MUTATION_IDS = "#{id}"; + String MUTATION_CODES = "#{code}"; + String MUTATION_APPS = "" + + "#{no}" + + "(NULL)"; + + /** 真实订单身份不携带暂扣资格,仓内作业只与运输取得相同的身份锁。 */ + record MutationOrder(String customerId, Long orderId) { } + /** 原生退货只有托盘码时保持ID为空,调用方必须通过权威资源提供者解析。 */ + record MutationPallet(Long palletId, String palletCode) { } + /** 组成重读比较逐位置、箱记录与换标明细,不以客户或首行代表整托。 */ + record MutationFact(String kind, Long id, String customerId, Long applicationId, Long referenceId, + Long palletId, String palletCode, Long quantity, Long version) { } + + /** 从全客户库存与原生退货扩展相关申请,不按排车资格过滤。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List mutationApplications(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds, + @Param("palletCodes") List palletCodes, @Param("applicationNos") List applicationNos); + + /** 原申请全部绑定实托参与共同串行集合;无托退货不伪造资源ID。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List mutationPallets(@Param("warehouseId") String warehouseId, @Param("applicationNos") List applicationNos); + + /** 收齐原入仓、退回实收、计划及原生退货业务订单,不使用MIN或其他客户代表行。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List mutationOrders(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds, + @Param("palletCodes") List palletCodes, @Param("applicationNos") List applicationNos); + + /** 逐申请业务号取锁,完整顺序由调用方在首次锁前确定。 */ + @Select("SELECT id FROM ll_relabel_application WHERE relabel_no=#{number} FOR UPDATE") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List lockMutationApplication(@Param("number") String number); + + /** 只锁真实订单身份,不为仓内作业附加暂扣或运输状态资格。 */ + @Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} FOR UPDATE") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long lockMutationOrder(@Param("customerId") String customerId, @Param("orderId") long orderId); + + /** 重读全部位置和逐申请绑定,发现同锁集合内的新成员也必须重试。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List mutationFacts(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds, + @Param("palletCodes") List palletCodes, @Param("applicationNos") List applicationNos); + + /** 共同实托锁持有期间检查正式和未过期草稿,过期判断使用调用方统一UTC业务时钟。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + long mutationTransportClaims(@Param("palletIds") List palletIds, @Param("at") java.time.LocalDateTime at); String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + "b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," + "p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java new file mode 100644 index 000000000..88d5e852c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.palletops.repository; + +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationFact; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationOrder; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationPallet; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; + +/** 仓内实托组成及身份锁的数据访问,不承担换标或运输资格判断。 */ +@Repository +public class PdaPalletOperationRepository { + private final PdaPalletOperationMapper mapper; + + /** 仅复用本域Mapper,不依赖排车内部服务或跨域Mapper。 */ + public PdaPalletOperationRepository(PdaPalletOperationMapper mapper) { this.mapper = mapper; } + + /** 未解析的原生退货托盘码由Service交给真实资源提供者解析,不伪造ID。 */ + public record Composition(List applications, List orders, + List pallets, List facts) { } + + /** 从全部客户的源目标实托扩展申请及其其他实托,保留完整原始组成。 */ + public Composition composition(String warehouseId, Map seedPallets, RelabelApplication source) { + var ids = new TreeSet<>(seedPallets.keySet()); + var codes = new TreeSet<>(seedPallets.values()); + var numbers = new TreeSet(); + if (source != null) numbers.add(source.getRelabelNo()); + List applications; + List related; + boolean changed; + do { + applications = mapper.mutationApplications(warehouseId, List.copyOf(ids), List.copyOf(codes), List.copyOf(numbers)); + changed = applications.stream().map(RelabelApplication::getRelabelNo).map(numbers::add).reduce(false, Boolean::logicalOr); + related = mapper.mutationPallets(warehouseId, List.copyOf(numbers)); + for (var pallet : related) { + if (pallet.palletId() != null && pallet.palletId() > 0) changed |= ids.add(pallet.palletId()); + if (pallet.palletCode() != null && !pallet.palletCode().isBlank()) changed |= codes.add(pallet.palletCode()); + } + } while (changed); + var pallets = new ArrayList(); + seedPallets.forEach((id,code) -> pallets.add(new MutationPallet(id,code))); + pallets.addAll(related); + return new Composition(List.copyOf(applications), + List.copyOf(mapper.mutationOrders(warehouseId,List.copyOf(ids),List.copyOf(codes),List.copyOf(numbers))), + List.copyOf(pallets), List.copyOf(mapper.mutationFacts(warehouseId,List.copyOf(ids),List.copyOf(codes),List.copyOf(numbers)))); + } + + /** 申请按调用方已收齐的业务号顺序逐行加锁。 */ + public boolean lockApplication(String number) { return mapper.lockMutationApplication(number).size() == 1; } + + /** 订单只锁身份,不添加暂扣或履约状态筛选。 */ + public boolean lockOrder(MutationOrder order) { return mapper.lockMutationOrder(order.customerId(),order.orderId()) != null; } + + /** 已持有共同串行点后读取正式及当前业务时钟下尚未过期的草稿占用。 */ + public long transportClaims(List pallets, LocalDateTime at) { return mapper.mutationTransportClaims(pallets,at); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 94419194d..fb8030484 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -12,6 +12,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; import com.baiyue.lastleg.palletops.model.PalletInventoryRow; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.entity.RelabelItem; @@ -42,6 +44,8 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; +import java.time.Clock; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -58,6 +62,9 @@ import java.util.stream.Collectors; @Service public class PdaPalletOperationService { @Resource private PdaPalletOperationMapper mapper; + @Resource private PdaPalletOperationRepository mutationRepository; + @Resource private PhysicalPalletSerialPort physicalPalletSerial; + private Clock mutationClock = Clock.systemUTC(); @Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Resource private InventoryPositionMapper positions; @Resource private InventoryBalanceMapper balances; @@ -75,11 +82,15 @@ public class PdaPalletOperationService { private TransactionTemplate transaction; private TransactionTemplate newTransaction; + /** 仓内组成预读和串行点后的复读使用RC,意图记录仍保留独立事务。 */ @Resource void configureTransactions(PlatformTransactionManager manager) { transaction = new TransactionTemplate(manager); + // 共同串行点之后必须看到其他事务的新成员,不能沿用MySQL默认RR的预读快照。 + transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); newTransaction = new TransactionTemplate(manager, definition); } @@ -218,6 +229,7 @@ public class PdaPalletOperationService { false)); } + /** 换标组托沿真实申请及箱贡献改绑,三个分支均先冻结完整实托组成。 */ public PalletOperationResponses.Operation relabelDisassemble(PdaWarehouseIdentity identity, PalletOperationRequests.RelabelDisassemble request) { PalletOperationResponses.Operation replay = replayIfPresent(identity, "RELABEL_PALLET_DISASSEMBLE", @@ -288,6 +300,8 @@ public class PdaPalletOperationService { } if (source.palletId() == target.palletId()) { return transaction.execute(status -> { + lockMutationComposition(identity, application, pallets); + requireCurrentRelabelItems(application,selected); var lockedApp = relabelApplications.lockAnyByRelabelNo(application.getRelabelNo()); requireRelabelWork(identity, lockedApp); long total = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), source.palletId()); @@ -359,6 +373,7 @@ public class PdaPalletOperationService { reason, relabelPlan); } + /** 原生退货及无来源托盘的箱绑定共用冻结门禁,远端失败在原事务持锁期间补偿。 */ private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity, RelabelApplication application, List selected, PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target, @@ -371,7 +386,7 @@ public class PdaPalletOperationService { long quantity = 0; List cartons = new ArrayList<>(); for (RelabelItem item : selected) { - ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId()); if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId()) || (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode())) : !Objects.equals(carton.getPalletCode(), source.palletCode())) @@ -388,9 +403,19 @@ public class PdaPalletOperationService { String requestHash = hash(request); PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash); if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); - var transition = physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托"); + PhysicalJournal journal = new PhysicalJournal(); + Boolean[] compensationResult = { null }; try { return transaction.execute(status -> { + Map pallets = new LinkedHashMap<>(); + if (source != null) pallets.put(source.palletCode(), source); + pallets.put(target.palletCode(), target); + lockMutationComposition(identity, application, pallets); + requireCurrentRelabelItems(application,selected); + requireRelabelDestination(identity.overseasWarehouseId(), selected, target); + // 先取得共同串行锁再触碰远端;本地失败补偿也必须在该锁释放前结束。 + try { + applyPhysical(operation,identity.overseasWarehouseId(),target,"OCCUPIED",target.locationId(),key,"退货换标组托",journal); for (int index = 0; index < cartons.size(); index++) { RelabelItem item = selected.get(index); ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); @@ -423,10 +448,16 @@ public class PdaPalletOperationService { operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation); return result; + } catch (RuntimeException failure) { + boolean restored = compensate(identity.overseasWarehouseId(),journal.confirmed); + compensationResult[0] = restored && !journal.awaitingReceipt; + if (journal.awaitingReceipt) failure.addSuppressed(new IllegalStateException("本次实托请求尚未取得有效回执,不能确认已补偿")); + throw failure; + } }); } catch (RuntimeException failure) { - try { physical.compensate(identity.overseasWarehouseId(), transition); } - catch (RuntimeException compensationFailure) { failure.addSuppressed(compensationFailure); } + boolean compensated = compensationResult[0] != null ? compensationResult[0] : journal.attempts.isEmpty(); + fail(operation.getId(),failure,compensated); throw failure; } } @@ -519,6 +550,7 @@ public class PdaPalletOperationService { plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true)); } + /** 所有托盘变更在库存锁之前冻结源目标完整组成,保留幂等与原事务持锁补偿。 */ private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key, Object request, String reason, Plan plan) { String normalizedKey = required(key, "幂等键不能为空"); @@ -526,9 +558,18 @@ public class PdaPalletOperationService { PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash); if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); - List transitions = new ArrayList<>(); + PhysicalJournal journal = new PhysicalJournal(); + List transitions = journal.confirmed; + Boolean[] compensationResult = { null }; try { PalletOperationResponses.Operation result = transaction.execute(status -> { + lockMutationComposition(identity, plan.relabelMutation() == null ? null : plan.relabelMutation().application(), plan.pallets()); + if (plan.relabelMutation() != null) { + var selected = plan.relabelMutation().itemsByPosition().values().stream().flatMap(List::stream).toList(); + requireCurrentRelabelItems(plan.relabelMutation().application(),selected); + for (var move : plan.moves()) requireRelabelDestination(identity.overseasWarehouseId(), selected, + plan.pallets().get(move.targetPalletCode())); + } if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId()); Long relabelOwner = plan.relabelMutation() == null ? null : inventoryCommands.relabelTransfer( plan.relabelMutation().application().getCustomerId(), plan.relabelMutation().application().getId()); @@ -547,18 +588,27 @@ public class PdaPalletOperationService { requirePalletReservation(anchor.getCustomerId(), anchorId, relabelOwner); } } + try { for (PalletChange change : plan.changes()) { - transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), + applyPhysical(operation,identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey, - change.pallet().palletId()), reason)); + change.pallet().palletId()), reason,journal); } - saveTransitions(operation.getId(), transitions); return mutate(identity, operation, plan, normalizedKey, requestHash, reason); + } catch (RuntimeException failure) { + // 普通及换标操作都持有共同串行点,不能先解锁再补偿旧的物理组成。 + boolean compensated = compensate(identity.overseasWarehouseId(), transitions); + compensationResult[0] = compensated && !journal.awaitingReceipt; + if (journal.awaitingReceipt) failure.addSuppressed(new IllegalStateException("本次实托请求尚未取得有效回执,局部补偿不代表整体恢复")); + if (!compensated) failure.addSuppressed(new IllegalStateException("实托流转补偿未完成")); + throw failure; + } }); if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果"); return result; } catch (RuntimeException failure) { - boolean compensated = compensate(identity.overseasWarehouseId(), transitions); + // 未进入物理阶段时没有远端变更;提交阶段异常不能解锁后自动回放旧补偿。 + boolean compensated = compensationResult[0] != null ? compensationResult[0] : journal.attempts.isEmpty(); fail(operation.getId(), failure, compensated); throw failure; } @@ -620,6 +670,97 @@ public class PdaPalletOperationService { return result; } + /** 首次申请锁前收齐闭包;申请、订单、实托共同串行点之后才允许读取库存并改绑。 */ + private void lockMutationComposition(PdaWarehouseIdentity identity, RelabelApplication source, + Map pallets) { + var seeds = new java.util.TreeMap(); + pallets.values().forEach(pallet -> seeds.put(pallet.palletId(),pallet.palletCode())); + var before = readMutationComposition(identity,source,seeds); + String expected = mutationCompositionIdentity(before); + var numbers = new java.util.TreeSet(); + before.composition().applications().forEach(app -> numbers.add(app.getRelabelNo())); + if (source != null && !numbers.contains(source.getRelabelNo())) throw conflict("换标申请的真实组成未完整返回"); + for (String number : numbers) if (!mutationRepository.lockApplication(number)) + throw conflict("换标申请身份已变化,请重新读取完整组成"); + var orders = new java.util.TreeMap(); + for (var order : before.composition().orders()) { + if (order.orderId() == null || order.orderId() <= 0 || order.customerId() == null || order.customerId().isBlank()) + throw conflict("实托来源缺少真实原始订单身份,不能改绑"); + var previous = orders.putIfAbsent(order.orderId(),order); + if (previous != null && !previous.customerId().equals(order.customerId())) + throw conflict("实托来源订单客户归属不一致"); + } + for (var order : orders.values()) if (!mutationRepository.lockOrder(order)) + throw conflict("实托来源订单不存在或归属已变化"); + physicalPalletSerial.lock(List.copyOf(before.pallets().keySet())); + var current = readMutationComposition(identity,source,seeds); + // 共同串行点之后发现新申请、订单、实托或贡献即整笔拒绝,不追加乱序锁。 + if (!expected.equals(mutationCompositionIdentity(current))) throw conflict("实托组成已变化,请重新读取后组托"); + for (var application : current.composition().applications()) requireUnallocatedRelabel(application); + if (source != null) { + var currentSource = current.composition().applications().stream() + .filter(app -> source.getRelabelNo().equals(app.getRelabelNo())).findFirst() + .orElseThrow(() -> conflict("换标申请身份已变化")); + requireRelabelWork(identity,currentSource); + } + var now = LocalDateTime.ofInstant(mutationClock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS),ZoneOffset.UTC); + if (mutationRepository.transportClaims(List.copyOf(current.pallets().keySet()),now) != 0) + throw conflict("实托已进入排车或有效草稿,请先解除运输占用后再组托"); + } + + /** 解析原生退货关联的真实托盘码,再重新展开全客户组成,绝不猜测资源ID。 */ + private MutationComposition readMutationComposition(PdaWarehouseIdentity identity, RelabelApplication source, + Map seeds) { + var resolved = new java.util.TreeMap<>(seeds); + PdaPalletOperationRepository.Composition composition; + boolean changed; + do { + composition = mutationRepository.composition(String.valueOf(identity.overseasWarehouseId()),resolved,source); + changed = false; + for (var pallet : composition.pallets()) { + if (pallet.palletCode() == null || pallet.palletCode().isBlank()) throw conflict("关联实托缺少真实托盘码"); + Long id = pallet.palletId(); + if (id == null) { + var known = resolved.entrySet().stream().filter(entry -> entry.getValue().equals(pallet.palletCode())).findFirst(); + id = known.isPresent() ? known.get().getKey() : requirePallet(identity.overseasWarehouseId(),pallet.palletCode()).palletId(); + } + if (id <= 0) throw conflict("关联实托缺少真实资源身份"); + var previous = resolved.putIfAbsent(id,pallet.palletCode()); + if (previous != null && !previous.equals(pallet.palletCode())) throw conflict("关联实托身份与托盘码不一致"); + changed |= previous == null; + } + } while (changed); + return new MutationComposition(composition,java.util.Collections.unmodifiableSortedMap(resolved)); + } + + /** 比较组成身份与贡献,不把预读对象或展示首行当作已锁定事实。 */ + private String mutationCompositionIdentity(MutationComposition value) { + var applications = value.composition().applications().stream().map(app -> java.util.Arrays.asList( + app.getId(),app.getRelabelNo(),app.getCustomerId(),app.getOverseasWarehouseId(),app.getLastlegOrderId(), + app.getStatus(),app.getFulfillmentStatus(),app.getOutboundOrderNo())).map(Object::toString).sorted().toList(); + return write(Map.of("applications",applications,"orders",value.composition().orders().stream().map(Object::toString).sorted().toList(), + "pallets",value.pallets(),"facts",value.composition().facts().stream().map(Object::toString).sorted().toList())); + } + + /** 本轮完整组成和已解析的全局实托集合。 */ + private record MutationComposition(PdaPalletOperationRepository.Composition composition, java.util.SortedMap pallets) { } + + /** 申请锁内复验请求预读的逐箱指针,不能用改绑前的旧明细覆盖当前贡献。 */ + private void requireCurrentRelabelItems(RelabelApplication application,List selected) { + for (var item : selected) { + var current = item.getId() == null ? null : relabelItems.selectById(item.getId()); + if (current == null || !Objects.equals(current.getApplicationId(),application.getId()) + || !Objects.equals(current.getCustomerId(),application.getCustomerId()) + || !Objects.equals(current.getSourceInventoryPositionId(),item.getSourceInventoryPositionId()) + || !Objects.equals(current.getReturnCartonId(),item.getReturnCartonId()) + || !Objects.equals(current.getCartonMark(),item.getCartonMark()) + || !Objects.equals(current.getNotifiedPackageCount(),item.getNotifiedPackageCount()) + || !Objects.equals(current.getDestinationFbaWarehouseCode(),item.getDestinationFbaWarehouseCode())) + throw conflict("换标箱的真实贡献或绑定已变化,请重新读取"); + } + } + + /** 已收齐全部申请锁后重核本次申请,重复取得同一申请不改变全局锁序。 */ private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) { RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo()); if (locked != null) requireUnallocatedRelabel(locked); @@ -1016,10 +1157,49 @@ public class PdaPalletOperationService { return read(existing).asReplay(); } - private void saveTransitions(long operationId, List transitions) { + /** 每次远端调用先保存完整意图,只有匹配的APPLIED回执才能清除当前未决事实。 */ + private void applyPhysical(PdaPalletOperation operation,long warehouseId,PalletPhysicalGateway.PalletReference pallet, + String targetStatus,Long targetLocationId,String key,String reason,PhysicalJournal journal) { + var attempt = new PhysicalAttempt(key,warehouseId,pallet.palletId(),pallet.palletCode(),pallet.version(), + pallet.status(),pallet.locationId(),targetStatus,targetLocationId,null); + journal.attempts.add(attempt); + savePhysicalAttempts(operation.getId(),journal.attempts); + // 从进入POST调用到有效回执之间均可能已在远端提交,不能按异常类型或HTTP码推定未写。 + journal.awaitingReceipt = true; + var receipt = physical.apply(warehouseId,pallet,targetStatus,targetLocationId,key,reason); + Long effectiveLocation = targetLocationId == null ? pallet.locationId() : targetLocationId; + if (receipt == null || !key.equals(receipt.operationKey()) || receipt.warehouseId()!=warehouseId + || receipt.palletId()!=pallet.palletId() || !Objects.equals(receipt.palletCode(),pallet.palletCode()) + || !Objects.equals(receipt.beforeStatus(),pallet.status()) || receipt.beforeVersion()!=pallet.version() + || !Objects.equals(receipt.beforeLocationId(),pallet.locationId()) + || !Objects.equals(receipt.afterStatus(),targetStatus) || !Objects.equals(receipt.afterLocationId(),effectiveLocation) + || (long)receipt.afterVersion()!=(long)pallet.version()+1 || !"APPLIED".equals(receipt.operationStatus())) + throw new IllegalStateException("实托流转未返回匹配本次意图的APPLIED回执,请保留原请求核对"); + journal.confirmed.add(receipt); + journal.awaitingReceipt = false; + journal.attempts.set(journal.attempts.size()-1,new PhysicalAttempt(key,warehouseId,pallet.palletId(),pallet.palletCode(),pallet.version(), + pallet.status(),pallet.locationId(),targetStatus,targetLocationId,receipt)); + savePhysicalAttempts(operation.getId(),journal.attempts); + } + + /** 已知回执与尚未确认的原请求分别保留,不用占位Transition冒充成功证据。 */ + private record PhysicalAttempt(String operationKey,long warehouseId,long palletId,String palletCode,int expectedVersion, + String beforeStatus,Long beforeLocationId,String targetStatus,Long targetLocationId, + PalletPhysicalGateway.Transition receipt) { } + + /** 本次作业所有意图和已确认回执;未决当前调用不属于可补偿回执集合。 */ + private static final class PhysicalJournal { + private final List attempts = new ArrayList<>(); + private final List confirmed = new ArrayList<>(); + private boolean awaitingReceipt; + } + + /** 意图证据独立保存,业务事务回滚或进程中断不能抹去已发远端请求。 */ + private void savePhysicalAttempts(long operationId, List attempts) { newTransaction.executeWithoutResult(status -> { PdaPalletOperation operation = mapper.selectById(operationId); - operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now()); + if (operation == null) throw new IllegalStateException("实托作业意图记录不存在,不能发出远端请求"); + operation.setRemoteTransitionsJson(write(attempts)); operation.setUpdatedAt(LocalDateTime.now()); mapper.updateById(operation); }); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java new file mode 100644 index 000000000..068903272 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.relabel.mapper; + +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.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */ +@Mapper +public interface RelabelDispatchSourceMapper { + String PALLETS = "#{id}"; + + /** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */ + @Select("") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List applicationIds(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds); + + /** 读取托盘全部库存位置,保留未被某申请引用的位置。 */ + @Select("") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List palletPositions(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds); + + /** 读取原申请身份和履约状态。 */ + @Select("SELECT * FROM ll_relabel_application WHERE id=#{id}") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + RelabelApplication application(@Param("id") Long id); + + /** 读取申请全部明细,防止只选择同申请的一部分实托。 */ + @Select("SELECT * FROM ll_relabel_item WHERE application_id=#{id} ORDER BY id") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List items(@Param("id") Long id); + + /** 读取当前任务及复核轮次,不按任务状态筛选。 */ + @Select("SELECT * FROM ll_relabel_pda_task WHERE application_id=#{id} ORDER BY id") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List tasks(@Param("id") Long id); + + /** 独立读取作业历史,避免与逐箱事实连接后放大贡献。 */ + @Select("SELECT * FROM ll_relabel_pda_history WHERE application_id=#{id} ORDER BY created_at,id") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List histories(@Param("id") Long id); + + /** 独立读取全部逐箱事实,由业务层识别当前轮次与归档记录。 */ + @Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE application_id=#{id} ORDER BY id") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List facts(@Param("id") Long id); + + /** 读取明细当前位置,不在此处取得正式库存锁。 */ + @Select("SELECT * FROM ll_inventory_position WHERE id=#{id}") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + InventoryPosition position(@Param("id") Long id); + + /** 仅按申请业务号取得原申请行锁,顺序由业务层统一排列。 */ + @Select("SELECT id FROM ll_relabel_application WHERE relabel_no=#{relabelNo} ORDER BY id FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List lockApplication(@Param("relabelNo") String relabelNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java new file mode 100644 index 000000000..e3a8ccbc8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.relabel.port; + +import java.time.LocalDateTime; +import java.util.List; + +/** 换标域向排车提供原始组成事实,不读取排车账本或代替排车锁定库存。 */ +public interface RelabelDispatchSourcePort { + /** 当前作业轮次的逐件成功或异常事实;未知件数保持空值。 */ + record UnitFact(Long id, String oldLabel, String newLabel, String result, Integer quantity) { } + + /** 原申请身份、内部作业状态、履约订单及客户确认轮次。 */ + record Application(Long applicationId, String relabelNo, String customerId, + Long fulfillmentOrderId, String status, String fulfillmentStatus, + String deliveryMethod, String roundKey, LocalDateTime updatedAt, + LocalDateTime customerConfirmedAt) { } + + /** 按申请与任务明细聚合的实际绑定贡献,原货源始终取明细冻结字段。 */ + record BoundSource(Long applicationId, String relabelNo, Long itemId, String customerId, + String warehouseId, Long warehousePalletId, Long inventoryPositionId, + Long inventoryBalanceId, Long sourceInventoryBalanceId, + Long sourceInboundOrderId, String sourceInboundOrderNo, + String sourceInboundCode, long notifiedPackageCount, + long boundPackageCount, Long productQuantity, String bindingBasisHash, + List unitFacts) { } + + /** 库存位置的原始箱数和版本,不在提供者内取得库存正式锁。 */ + record Position(Long positionId, String customerId, Long inventoryBalanceId, + Long warehousePalletId, long availablePackages, long holdPackages, + long allocatedPackages, long pickedPackages, long receivingPackages, + long recordVersion) { } + + /** 从实托扩展关联申请及全部绑定明细的组成快照,资格判断仍由消费方负责。 */ + record Snapshot(List applications, List sources, + List positions, String compositionVersion, + boolean complete, List blockReasons) { } + + /** 只读取得完整原始组成,不按当前页面筛选申请或业务状态。 */ + Snapshot snapshot(String warehouseId, List warehousePalletIds); + + /** 在调用方现存事务内按业务号稳定顺序一次锁齐原申请。 */ + void lockApplications(List relabelNos); + + /** 调用方持有申请锁后重新读取组成;库存位置仍由排车域统一锁定。 */ + Snapshot lockedSnapshot(String warehouseId, List warehousePalletIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java new file mode 100644 index 000000000..2adcf5885 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.relabel.repository; + +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.mapper.RelabelDispatchSourceMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** 从请求实托递归扩展申请与全部关联实托,始终通过实际 Mapper 读写。 */ +@Repository +public class RelabelDispatchSourceRepository { + private final RelabelDispatchSourceMapper mapper; + + /** 注入换标事实查询映射器。 */ + public RelabelDispatchSourceRepository(RelabelDispatchSourceMapper mapper) { + this.mapper = mapper; + } + + /** 单个申请独立读取的原始持久化事实,尚未进行业务资格判断。 */ + public record ApplicationRows(RelabelApplication application, List items, + List tasks, List histories, + List facts) { } + + /** 闭包中全部申请与库存位置;缺失数据作为不完整原因返回。 */ + public record Closure(List applications, List positions, + List blockReasons) { } + + /** 扩展到固定点:实托上的全部申请、申请的全部明细、明细所在实托上的其他申请。 */ + public Closure closure(String warehouseId, List requestedPallets) { + Set pending = new TreeSet<>(requestedPallets); + Set visitedPallets = new LinkedHashSet<>(); + Set visitedApplications = new LinkedHashSet<>(); + Map positions = new TreeMap<>(); + List applications = new ArrayList<>(); + List reasons = new ArrayList<>(); + while (!pending.isEmpty()) { + List batch = List.copyOf(pending); + pending.clear(); + visitedPallets.addAll(batch); + mapper.palletPositions(warehouseId, batch).forEach(position -> positions.put(position.getId(), position)); + for (Long applicationId : mapper.applicationIds(warehouseId, batch)) { + if (!visitedApplications.add(applicationId)) continue; + RelabelApplication application = mapper.application(applicationId); + if (application == null) { + reasons.add("换标明细缺少所属申请"); + continue; + } + // 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。 + // 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。 + if ("CANCELLED".equals(application.getStatus())) continue; + List items = mapper.items(applicationId); + applications.add(new ApplicationRows(application, items, mapper.tasks(applicationId), + mapper.histories(applicationId), mapper.facts(applicationId))); + for (RelabelItem item : items) { + if (item.getSourceInventoryPositionId() == null) continue; + InventoryPosition position = positions.get(item.getSourceInventoryPositionId()); + if (position == null) position = mapper.position(item.getSourceInventoryPositionId()); + if (position == null) continue; + positions.put(position.getId(), position); + if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null + && !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId()); + } + } + } + applications.sort(java.util.Comparator.comparing(value -> value.application().getRelabelNo())); + return new Closure(List.copyOf(applications), List.copyOf(positions.values()), List.copyOf(reasons)); + } + + /** 按单一业务号锁定原申请,返回匹配行数以识别缺失或重复身份。 */ + public int lockApplication(String relabelNo) { + return mapper.lockApplication(relabelNo).size(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java new file mode 100644 index 000000000..28a643181 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java @@ -0,0 +1,345 @@ +package com.baiyue.lastleg.relabel.service; + +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.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository.ApplicationRows; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** 普通换标的原申请与实托组成提供者;业务资格和正式库存锁由排车消费方负责。 */ +@Service +public class RelabelDispatchSourceService implements RelabelDispatchSourcePort { + private final RelabelDispatchSourceRepository repository; + private final ObjectMapper json; + private final Object applicationLocks = new Object(); + + /** 注入持久化事实入口和历史解析器。 */ + public RelabelDispatchSourceService(RelabelDispatchSourceRepository repository, ObjectMapper json) { + this.repository = repository; + this.json = json.copy().findAndRegisterModules(); + } + + /** 读取所有关联申请和库存组成,保留未确认、异常及其他不可用状态。 */ + @Override + @Transactional(readOnly = true) + public Snapshot snapshot(String warehouseId, List warehousePalletIds) { + return readSnapshot(warehouseId, normalizePallets(warehouseId, warehousePalletIds)); + } + + /** 一次锁齐按业务号排序的原申请,禁止在事务中追加另一批锁。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void lockApplications(List relabelNos) { + requireTransaction(); + if (relabelNos == null || relabelNos.stream().anyMatch(no -> no == null || no.isBlank())) + throw new IllegalArgumentException("换标申请业务号不能为空"); + Set ordered = new TreeSet<>(relabelNos); + Object previous = TransactionSynchronizationManager.getResource(applicationLocks); + if (previous != null) { + if (!previous.equals(ordered)) throw new IllegalStateException("本事务已锁定换标申请,不能追加或替换锁集合"); + return; + } + for (String relabelNo : ordered) { + if (repository.lockApplication(relabelNo) != 1) + throw new IllegalStateException("换标申请不存在或业务号不唯一:" + relabelNo); + } + TransactionSynchronizationManager.bindResource(applicationLocks, Set.copyOf(ordered)); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 挂起调用方事务时同步挂起锁凭证,避免新事务借用旧事务的锁。 */ + @Override + public void suspend() { + TransactionSynchronizationManager.unbindResourceIfPossible(applicationLocks); + } + + /** 恢复调用方事务时恢复该事务自己的锁凭证。 */ + @Override + public void resume() { + TransactionSynchronizationManager.bindResource(applicationLocks, Set.copyOf(ordered)); + } + + /** 事务结束后清除锁凭证,防止下一事务复用。 */ + @Override + public void afterCompletion(int status) { + TransactionSynchronizationManager.unbindResourceIfPossible(applicationLocks); + } + }); + } + + /** 重读完整闭包,并拒绝申请锁集合以外的新组成,不抢先锁库存位置。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public Snapshot lockedSnapshot(String warehouseId, List warehousePalletIds) { + requireTransaction(); + Object resource = TransactionSynchronizationManager.getResource(applicationLocks); + if (!(resource instanceof Set locked)) throw new IllegalStateException("须先一次锁齐换标原申请"); + Snapshot current = readSnapshot(warehouseId, normalizePallets(warehouseId, warehousePalletIds)); + if (current.applications().stream().anyMatch(app -> !locked.contains(app.relabelNo()))) + throw new IllegalStateException("换标实托组成已变化,请重新读取并锁定全部申请"); + return current; + } + + /** 从闭包生成按申请、明细聚合的事实,不连接逐箱和历史表放大数量。 */ + private Snapshot readSnapshot(String warehouseId, List palletIds) { + var closure = repository.closure(warehouseId, palletIds); + Set reasons = new LinkedHashSet<>(closure.blockReasons()); + Map positions = new HashMap<>(); + closure.positions().forEach(position -> positions.put(position.getId(), position)); + List applications = new ArrayList<>(); + List sources = new ArrayList<>(); + for (ApplicationRows rows : closure.applications()) { + RelabelApplication app = rows.application(); + PdaRelabelTask task = rows.tasks().size() == 1 ? rows.tasks().get(0) : null; + if (task == null || !Objects.equals(task.getCustomerId(), app.getCustomerId()) + || !Objects.equals(task.getRelabelNo(), app.getRelabelNo()) + || !Objects.equals(Objects.toString(task.getOverseasWarehouseId(), null), app.getOverseasWarehouseId())) + reasons.add(app.getRelabelNo() + ":作业任务缺失或归属不一致"); + if (!Objects.equals(warehouseId, app.getOverseasWarehouseId())) + reasons.add(app.getRelabelNo() + ":申请与实托不属于同一仓库"); + List histories = currentHistories(rows, task, reasons); + LocalDateTime archivedAt = rows.histories().stream().filter(h -> "REWORK_ARCHIVE".equals(h.getAction())) + .filter(h -> task != null && Objects.equals(h.getTaskId(), task.getId()) + && Objects.equals(h.getCustomerId(), app.getCustomerId())) + .map(PdaRelabelHistory::getCreatedAt).filter(Objects::nonNull).max(LocalDateTime::compareTo).orElse(null); + // 客户确认会重写任务 reviewedAt;作业轮次只能取最后返工边界后的生产者历史。 + String round = currentRound(histories, archivedAt, reasons, app.getRelabelNo()); + applications.add(new Application(app.getId(), app.getRelabelNo(), app.getCustomerId(), + app.getLastlegOrderId(), app.getStatus(), app.getFulfillmentStatus(), app.getDeliveryMethod(), + round, app.getUpdatedAt(), app.getCustomerConfirmedAt())); + List facts = rows.facts().stream() + .filter(f -> !"REWORK_ARCHIVED".equals(f.getResult())) + .filter(f -> task != null && Objects.equals(f.getTaskId(), task.getId()) + && Objects.equals(f.getCustomerId(), app.getCustomerId())) + .filter(f -> archivedAt == null || (f.getOccurredAt() != null && f.getOccurredAt().isAfter(archivedAt))) + .toList(); + if (rows.facts().stream().filter(f -> !"REWORK_ARCHIVED".equals(f.getResult())).count() != facts.size()) + reasons.add(app.getRelabelNo() + ":存在归属不一致或旧轮次未归档的作业事实"); + // 独立的申请、明细聚合保留成功与异常事实;不得用成功数冒充绑定数。 + for (RelabelItem item : rows.items()) { + InventoryPosition position = positions.get(item.getSourceInventoryPositionId()); + List itemFacts = facts.stream() + .filter(f -> Objects.equals(f.getItemId(), item.getId())).toList(); + boolean bound = bindingMatches(item, position, histories, round, reasons, app.getRelabelNo()); + long notified = number(item.getNotifiedPackageCount()); + if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null + || !Objects.equals(position.getCustomerId(), app.getCustomerId()) + || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) { + reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致"); + bound = false; + } + if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + reasons.add(app.getRelabelNo() + ":退货原生换标须使用退货组成提供者"); + bound = false; + } + if (notified <= 0) reasons.add(app.getRelabelNo() + ":通知箱数无效"); + if (!bound) reasons.add(app.getRelabelNo() + ":明细缺少当前轮次的有效实托绑定"); + String basis = hash(java.util.Arrays.asList(app, task, item, position, histories, itemFacts)); + sources.add(new BoundSource(app.getId(), app.getRelabelNo(), item.getId(), item.getCustomerId(), + position == null ? warehouseId : position.getOverseasWarehouseId(), + position == null ? null : position.getPalletId(), item.getSourceInventoryPositionId(), + position == null ? null : position.getBalanceId(), item.getSourceInventoryBalanceId(), + item.getSourceInboundOrderId(), item.getSourceInboundOrderNo(), item.getSourceInboundCode(), + notified, bound ? notified : 0, productQuantity(item, itemFacts, histories, round), basis, + itemFacts.stream().map(f -> new UnitFact(f.getId(), f.getOldLabel(), f.getNewLabel(), + f.getResult(), f.getQuantity())).toList())); + } + if (rows.items().isEmpty()) reasons.add(app.getRelabelNo() + ":申请明细为空"); + if (facts.stream().anyMatch(f -> rows.items().stream().noneMatch(i -> Objects.equals(i.getId(), f.getItemId())))) + reasons.add(app.getRelabelNo() + ":作业事实缺少所属明细"); + } + Map contributions = new HashMap<>(); + sources.forEach(source -> { + if (source.inventoryPositionId() != null) + contributions.merge(source.inventoryPositionId(), source.boundPackageCount(), Math::addExact); + }); + // 多申请共用位置必须合计比较 HOLD,不能让每张申请分别通过后重复消费。 + contributions.forEach((id, total) -> { + InventoryPosition position = positions.get(id); + if (position != null && total > number(position.getHoldPackages())) + reasons.add("实托绑定总箱数超过该位置实际 HOLD 箱数"); + }); + List positionFacts = closure.positions().stream().map(p -> new Position(p.getId(), p.getCustomerId(), + p.getBalanceId(), p.getPalletId(), number(p.getAvailablePackages()), number(p.getHoldPackages()), + number(p.getAllocatedPackages()), number(p.getPickedPackages()), number(p.getReceivingPackages()), + number(p.getVersion()))).toList(); + String version = hash(List.of(warehouseId, palletIds, closure, applications, sources, positionFacts)); + return new Snapshot(List.copyOf(applications), List.copyOf(sources), positionFacts, version, + reasons.isEmpty(), List.copyOf(reasons)); + } + + /** 清除返工归档前历史,只保留同客户、同任务的原始当前历史。 */ + private List currentHistories(ApplicationRows rows, PdaRelabelTask task, Set reasons) { + List current = new ArrayList<>(); + for (PdaRelabelHistory history : rows.histories()) { + if (task == null || !Objects.equals(history.getCustomerId(), rows.application().getCustomerId()) + || !Objects.equals(history.getTaskId(), task.getId())) { + reasons.add(rows.application().getRelabelNo() + ":作业历史归属不一致"); + continue; + } + if ("REWORK_ARCHIVE".equals(history.getAction())) current.clear(); + else current.add(history); + } + return List.copyOf(current); + } + + /** 从实际完工、绑定及扫码历史识别轮次;返工前轮次即使出现在边界后也不能复用。 */ + private String currentRound(List histories, LocalDateTime archivedAt, + Set reasons, String relabelNo) { + Set recorded = new LinkedHashSet<>(); + String completed = null; + for (PdaRelabelHistory history : histories) { + if (!Set.of("COMPLETE", "PALLET_BIND", "SKU_SCAN", "MIXED_CARTON", "MIXED_SKU", "RETURN_PACK") + .contains(Objects.toString(history.getAction(), ""))) continue; + JsonNode data = detail(history, reasons, relabelNo); + String round = data.path("COMPLETE".equals(history.getAction()) ? "roundKey" : "round").asText(null); + if (round == null || round.isBlank()) { + reasons.add(relabelNo + ":作业历史缺少轮次"); + continue; + } + if (!afterReworkBoundary(round, archivedAt)) { + reasons.add(relabelNo + ":返工后存在旧轮次作业历史"); + continue; + } + recorded.add(round); + if ("COMPLETE".equals(history.getAction())) completed = round; + } + if (recorded.size() != 1) { + reasons.add(relabelNo + (recorded.isEmpty() ? ":未找到当前作业轮次" : ":当前作业历史轮次不一致")); + return null; + } + return completed == null ? recorded.iterator().next() : completed; + } + + /** 返工轮次由生产者写入复核时间文本,允许数据库毫秒精度下与归档边界同刻。 */ + private boolean afterReworkBoundary(String round, LocalDateTime archivedAt) { + if (archivedAt == null) return true; + try { + return !LocalDateTime.parse(round).isBefore(archivedAt); + } catch (DateTimeParseException invalid) { + return false; + } + } + + /** 专用绑定必须同时命中当前轮次、箱唛与当前位置实托码。 */ + private boolean bindingMatches(RelabelItem item, InventoryPosition position, List histories, + String round, Set reasons, String relabelNo) { + if (round == null) return false; + boolean bound = false; + for (PdaRelabelHistory history : histories) { + if (!"PALLET_BIND".equals(history.getAction())) continue; + JsonNode detail = detail(history, reasons, relabelNo); + if (!Objects.equals(round, detail.path("round").asText(null))) continue; + if (contains(detail.path("cartonMarks"), item.getCartonMark())) + bound = position != null && contains(detail.path("targetPallets"), position.getPalletNo()); + } + return bound; + } + + /** 仅以本轮实际 SKU 扫描确认件数;纯箱标及缺少逐 SKU 事实保持未知。 */ + private Long productQuantity(RelabelItem item, List facts, + List histories, String round) { + if (round == null || !Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(Objects.toString(item.getOperationType(), "")) + || facts.stream().noneMatch(f -> "RELABELLED".equals(f.getResult()))) return null; + try { + JsonNode expected = json.readTree(Objects.toString(item.getSkuRelabelItemsJson(), "[]")); + if (!expected.isArray() || expected.isEmpty()) return null; + Map scanned = new HashMap<>(); + for (PdaRelabelHistory history : histories) { + if (!"SKU_SCAN".equals(history.getAction())) continue; + JsonNode data = json.readTree(history.getDetailJson()); + if (!Objects.equals(round, data.path("round").asText(null)) + || !Objects.equals(item.getId().toString(), data.path("itemId").asText())) continue; + int index = data.path("skuIndex").asInt(-1); + long quantity = data.path("quantity").asLong(-1); + if (index < 0 || index >= expected.size() || quantity <= 0 || scanned.putIfAbsent(index, quantity) != null) + return null; + } + if (scanned.size() != expected.size()) return null; + long total = 0; + for (int index = 0; index < expected.size(); index++) { + if (expected.get(index).path("quantity").asLong(-1) != scanned.get(index)) return null; + total = Math.addExact(total, scanned.get(index)); + } + return total; + } catch (JsonProcessingException | ArithmeticException invalid) { + return null; + } + } + + /** 解析绑定历史;损坏记录显式阻止宣称快照完整。 */ + private JsonNode detail(PdaRelabelHistory history, Set reasons, String relabelNo) { + try { + JsonNode parsed = json.readTree(Objects.toString(history.getDetailJson(), "")); + if (parsed != null && parsed.isObject()) return parsed; + } catch (JsonProcessingException invalid) { + reasons.add(relabelNo + ":实托绑定历史无法读取"); + } + reasons.add(relabelNo + ":实托绑定历史缺少对象结构"); + return json.createObjectNode(); + } + + /** 严格按原业务码判断历史中的箱唛或实托码。 */ + private boolean contains(JsonNode values, String expected) { + if (expected == null || !values.isArray()) return false; + for (JsonNode value : values) if (expected.equals(value.asText())) return true; + return false; + } + + /** 统一组成和绑定摘要,保留原始位置版本、申请更新及返工历史变化。 */ + private String hash(Object value) { + try { + byte[] encoded = json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(encoded)); + } catch (JsonProcessingException | NoSuchAlgorithmException failure) { + throw new IllegalStateException("无法生成换标组成版本", failure); + } + } + + /** 实托集合必须明确且仅含有效身份;排序去重使相同选择拥有稳定摘要。 */ + private List normalizePallets(String warehouseId, List ids) { + if (warehouseId == null || warehouseId.isBlank() || ids == null || ids.isEmpty() + || ids.stream().anyMatch(id -> id == null || id <= 0)) + throw new IllegalArgumentException("仓库和实托集合不能为空"); + return List.copyOf(new TreeSet<>(ids)); + } + + /** 空数量只作为零事实输出,不推导商品件数。 */ + private long number(Number value) { + return value == null ? 0 : value.longValue(); + } + + /** 直接调用同样要求调用方事务,避免绕过 Spring 代理后无事务取锁。 */ + private void requireTransaction() { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || !TransactionSynchronizationManager.isSynchronizationActive()) + throw new IllegalStateException("换标申请锁与锁后重读必须加入调用方现存事务"); + if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) + throw new IllegalStateException("换标申请锁必须加入调用方写事务"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index b8ea1c5f4..c044ab501 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -1,6 +1,6 @@ package com.baiyue.lastleg.security; -/** Canonical authorities for overseas browser APIs. */ +/** 海外浏览器接口唯一正式权限编码,读写动作分别授权。 */ public final class OverseasPermissions { public static final String APPOINTMENT_LIST = "platform:appointment:list"; public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list"; @@ -13,8 +13,6 @@ public final class OverseasPermissions { public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit"; public static final String INBOUND_ORDER_FBA_APPOINTMENT = "platform:overseas-warehouse:inbound-order:fba-appointment"; - public static final String INBOUND_ORDER_SMART_DISPATCH = - "platform:overseas-warehouse:inbound-order:smart-dispatch"; public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list"; public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create"; public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status"; @@ -30,6 +28,9 @@ public final class OverseasPermissions { public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status"; public static final String DELIVERY_DOCUMENTS = "platform:overseas-warehouse:delivery:documents"; public static final String DISPATCH_LIST = "platform:overseas-warehouse:dispatch:list"; + public static final String DISPATCH_CREATE = "platform:overseas-warehouse:dispatch:create"; + public static final String DISPATCH_LOADING_COMPLETE = "platform:overseas-warehouse:dispatch:loading-complete"; + public static final String DISPATCH_DEPART = "platform:overseas-warehouse:dispatch:depart"; public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit"; public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel"; public static final String DISPATCH_TRANSFER_DELIVERY = @@ -43,7 +44,6 @@ public final class OverseasPermissions { public static final String CUSTOMER_VISIT_LIST = "platform:overseas-warehouse:customer-visit:list"; public static final String CUSTOMER_VISIT_EDIT = "platform:overseas-warehouse:customer-visit:edit"; public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list"; - public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create"; public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list"; public static final String MERCHANT_RETURN_INVENTORY_LIST = "merchant:overseas-warehouse:return-inventory:list"; @@ -121,5 +121,6 @@ public final class OverseasPermissions { public static final String DRIVER_REVIEW = "platform:overseas-master-data:driver:review"; public static final String DRIVER_UPLOAD = "platform:overseas-master-data:driver:upload"; + /** 权限常量容器不允许实例化。 */ private OverseasPermissions() { } } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index afd7a2b27..a0ba74737 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1103,8 +1103,8 @@ CREATE TABLE ll_fleet_trip ( max_pallet_spaces INT NULL COMMENT '分配时托位容量快照', max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克', max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米', - planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC', - expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + planned_departure_at DATETIME(3) NULL COMMENT '计划发车UTC,空单草稿可未填写,提交审核前核齐', + expected_driver_end_at DATETIME(3) NULL COMMENT '司机预计任务结束UTC,含返程转场,草稿可未填写', departed_at DATETIME(3) NULL COMMENT '实际发车UTC', completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC', stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点', @@ -1276,20 +1276,18 @@ CREATE TABLE ll_fleet_resource_assignment ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; CREATE TABLE ll_fleet_trip_dispatch ( - dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', - dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号', - customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属', - trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用', - trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号', + dispatch_id BIGINT NOT NULL COMMENT '整车排车单内部引用,一车一个有效资源关联', + dispatch_no VARCHAR(64) NOT NULL COMMENT '整车排车业务单号', + trip_id BIGINT NOT NULL COMMENT '本趟运输资源记录内部引用', + trip_no VARCHAR(32) NOT NULL COMMENT '本趟运输资源业务编号', package_count INT NOT NULL COMMENT '关联时待发运箱数快照', bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人', bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间', - review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核', PRIMARY KEY (dispatch_id), UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no), - KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id), + UNIQUE KEY uk_fleet_trip_dispatch_trip (trip_id), CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='整车排车与本趟资源记录一对一关联,历史独立保留'; CREATE TABLE ll_fleet_trip_history ( id BIGINT NOT NULL COMMENT '内部主键', @@ -1892,38 +1890,68 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( ) 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 '车辆预约状态', + id BIGINT NOT NULL COMMENT '整车排车主单内部主键', + dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', + trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '起运海外仓名称快照', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '主单生命周期:ACTIVE有效、CANCELLED已取消', + stage VARCHAR(32) NOT NULL DEFAULT 'BEFORE_LOADING' COMMENT '作业阶段:BEFORE_LOADING装车前、LOADING装车中、AFTER_LOADING装车后、DELIVERING派送中、DELIVERED派送完成', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '整车公共并发版本,首版为零', + cargo_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本趟货物修订版本', + cargo_hash CHAR(64) NOT NULL COMMENT '当前整车真实货物及来源摘要', + review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', + review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次', + review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照', + loading_review_json JSON NULL COMMENT '当前理货审核事实快照', + resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', + vehicle_plate VARCHAR(64) NULL COMMENT '本趟车辆真实车牌快照', + route VARCHAR(2000) NULL COMMENT '真实运输路线展示快照', + planned_departure_at DATETIME(3) NULL 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 '更新时间', + loading_completed_at DATETIME(3) NULL COMMENT '实际确认装车完成时间', + loading_completion_hash CHAR(64) NULL COMMENT '实际装车完成事实摘要', + loading_confirmation_json JSON NULL COMMENT '装车确认时逐实托真实实载完整快照', + arrangement_key VARCHAR(128) NULL COMMENT '本车唯一派送安排事实标识', + arrangement_business_hash CHAR(64) NULL COMMENT '本车派送安排业务内容摘要', + departed_at DATETIME(3) NULL COMMENT '本趟实际发车时间', + delivery_completed_at DATETIME(3) NULL 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 '本车实际发运箱数', + remark VARCHAR(2000) 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 (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='尾程派车单'; + UNIQUE KEY uk_dispatch_trip (trip_id), + KEY idx_ll_dispatch_order_list (overseas_warehouse_id,status,stage,created_at,id), + KEY idx_ll_dispatch_order_vehicle (vehicle_plate,status,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台整车排车作业主单,客户归属在真实明细'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_history ( + id BIGINT NOT NULL COMMENT '排车历史内部主键', + dispatch_id BIGINT NOT NULL COMMENT '本次历史所属整车主单ID', + record_version BIGINT NOT NULL COMMENT '操作完成后的整车公共版本', + action VARCHAR(40) NOT NULL COMMENT '本次真实业务动作', + operator_id VARCHAR(128) NOT NULL COMMENT '可信操作主体ID', + reason VARCHAR(2000) NULL COMMENT '审核驳回或变更原因', + snapshot_json JSON NOT NULL COMMENT '动作完成当时的完整主单事实快照', + occurred_at DATETIME(3) NOT NULL COMMENT '真实操作发生时间', + PRIMARY KEY (id), + KEY idx_dispatch_history_version (dispatch_id,record_version,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车版本与审核轮次的追加历史'; CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + customer_name VARCHAR(255) NULL COMMENT '本条真实客户名称快照', + source_key VARCHAR(255) NOT NULL COMMENT '本条货物稳定权威来源键', + source_version CHAR(64) NOT NULL COMMENT '选货时核对的真实来源版本摘要', + source_snapshot_json JSON NOT NULL COMMENT '本条货物不可变来源及关联事实快照', id BIGINT NOT NULL COMMENT '主键ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -1931,7 +1959,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( 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_no VARCHAR(128) NULL COMMENT '本条真实来源订单业务号快照', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', @@ -1958,7 +1986,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', PRIMARY KEY (id), - UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,source_key), 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) @@ -1970,8 +1998,67 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE 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='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +CREATE TABLE IF NOT EXISTS ll_dispatch_item_source ( + id BIGINT NOT NULL COMMENT '逻辑运输来源贡献快照主键', + dispatch_order_id BIGINT NOT NULL COMMENT '所属整车排车主单ID', + dispatch_item_id BIGINT NOT NULL COMMENT '所属客户逻辑排车明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '本条真实客户ID', + relabel_application_id BIGINT NOT NULL COMMENT '换标申请ID', + relabel_item_id BIGINT NOT NULL COMMENT '换标申请原明细ID', + relabel_round_key VARCHAR(128) NOT NULL COMMENT '冻结的真实换标作业轮次,扫码数量变化不改变轮次', + warehouse_pallet_id BIGINT NOT NULL COMMENT '当前真实物理托盘ID', + inventory_position_id BIGINT NOT NULL COMMENT '绑定后实际库存位置ID', + inventory_balance_id BIGINT NOT NULL COMMENT '绑定后实际库存余额ID', + source_inventory_balance_id BIGINT NULL COMMENT '原货源库存余额快照,未知为空', + source_inbound_order_id BIGINT NULL COMMENT '逐换标明细原货源订单ID,未知为空', + source_inbound_order_no VARCHAR(128) NULL COMMENT '逐换标明细原货源订单业务号快照', + source_inbound_code VARCHAR(128) NULL COMMENT '逐换标明细原入仓业务号快照', + notified_package_count BIGINT NOT NULL COMMENT '本来源通知箱数,不代表全部换标成功', + package_count BIGINT NOT NULL COMMENT '本来源实际绑定的可拣箱数', + product_quantity BIGINT NULL COMMENT '有真实依据的来源产品件数,未知为空', + binding_basis_hash CHAR(64) NOT NULL COMMENT '申请轮次、位置及逐来源贡献的冻结依据摘要', + unit_facts_json JSON NOT NULL COMMENT '逐件新旧箱唛及成功或异常工作事实快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_item_source_contribution (dispatch_item_id,relabel_item_id,inventory_position_id), + KEY idx_dispatch_item_source_dispatch (dispatch_order_id,dispatch_item_id), + KEY idx_dispatch_item_source_relabel (customer_id,relabel_application_id,relabel_item_id), + CONSTRAINT chk_dispatch_item_source_packages CHECK(notified_package_count>=0 AND package_count>0), + CONSTRAINT chk_dispatch_item_source_quantity CHECK(product_quantity IS NULL OR product_quantity>=0) +) ENGINE=InnoDB COMMENT='换标逻辑来源实际贡献不可变快照'; + +CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard ( + warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间', + PRIMARY KEY (warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='排车冻结与仓内实托组成修改的共同串行点'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_physical_pallet_lock ( + id BIGINT NOT NULL COMMENT '物理实托占用主键', + warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID', + active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空', + dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥', + draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥', + draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID', + operator_id VARCHAR(128) NULL COMMENT '占用操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态', + expires_at DATETIME(3) NULL 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_dispatch_physical_active_pallet (active_warehouse_pallet_id), + KEY idx_dispatch_physical_order (dispatch_order_id,status), + KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status), + CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)), + CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL)) +) ENGINE=InnoDB COMMENT='同车共享且跨车排他的物理实托占用'; + CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( id BIGINT NOT NULL COMMENT '主键ID', + physical_lock_id BIGINT 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 '生效中的资源排他键', @@ -1992,15 +2079,19 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), + KEY idx_dispatch_logical_physical (physical_lock_id,status), 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='派车托盘排他锁'; +) ENGINE=InnoDB COMMENT='客户逻辑运输来源占用'; CREATE TABLE IF NOT EXISTS ll_dispatch_draft ( id BIGINT NOT NULL COMMENT '主键ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', - customer_id VARCHAR(128) NULL COMMENT '客户租户ID', operator_id VARCHAR(128) NULL COMMENT '草稿操作人ID', - plan_ids_json VARCHAR(4000) NULL COMMENT '所选入仓计划ID列表JSON', containers_json LONGTEXT NULL COMMENT '草稿装车布局JSON快照', - status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', expires_at DATETIME(3) NULL COMMENT '草稿失效时间', + operator_id VARCHAR(128) NOT NULL COMMENT '草稿拥有者身份,仅本人可维护及恢复命令', + warehouse_id BIGINT NOT NULL COMMENT '本草稿唯一起运公共仓库引用', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '草稿并发版本,显式零版本合法', + sources_json LONGTEXT NOT NULL COMMENT '服务端核验的真实来源及客户版本快照数组', + plan_ids_json LONGTEXT NULL COMMENT '所选入仓计划ID列表JSON,容纳最多五百个完整长整型引用', containers_json LONGTEXT NULL COMMENT '草稿装车布局JSON快照', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态:ACTIVE有效、RELEASED释放、CONVERTED已整批转换', expires_at DATETIME(3) 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_draft_token(draft_token), KEY idx_ll_dispatch_draft_operator(operator_id,status) @@ -2009,10 +2100,25 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab CREATE TABLE IF NOT EXISTS ll_dispatch_draft_container ( id BIGINT NOT NULL COMMENT '主键ID', draft_id BIGINT NOT NULL COMMENT '智能排车草稿ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', sequence_no INT NOT NULL COMMENT '草稿车厢顺序号', + source_keys_json LONGTEXT NOT NULL COMMENT '本装车容器已取得本草稿占用的来源键数组', capacity INT 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_dispatch_draft_container_sequence(draft_id,sequence_no), KEY idx_ll_dispatch_draft_container_token(draft_token) - ) ENGINE=InnoDB COMMENT='智能排车草稿装车托盘'; + ) ENGINE=InnoDB COMMENT='智能排车草稿装车容器,每个非空容器转换一张整车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_draft_command ( + id BIGINT NOT NULL COMMENT '草稿命令内部主键', + idempotency_key VARCHAR(128) NOT NULL COMMENT '草稿操作全局唯一幂等键', + operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作主体,恢复必须为本人', + action VARCHAR(32) NOT NULL COMMENT '草稿创建、维护、选货、释放或转换动作', + target_token VARCHAR(128) NOT NULL COMMENT '命令目标草稿标识或创建范围标识', + request_hash CHAR(64) NOT NULL COMMENT '原动作、主体、版本及完整载荷摘要', + result_json LONGTEXT NULL COMMENT '本事务完成后保存的不可变原结果,不提交空结果', + created_at DATETIME(3) NOT NULL COMMENT '命令创建UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_draft_command_key (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能排车草稿持久命令及原结果'; 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='draft_container_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_container_id BIGINT NULL COMMENT ''智能排车草稿装车托盘ID'' AFTER dispatch_order_id')); PREPARE dispatch_draft_container_column_stmt FROM @ddl; EXECUTE dispatch_draft_container_column_stmt; DEALLOCATE PREPARE dispatch_draft_container_column_stmt; @@ -2030,15 +2136,15 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 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', + id BIGINT NOT NULL COMMENT '主键ID', dispatch_order_id BIGINT NOT NULL COMMENT '唯一整车排车单引用,客户归属保留在扫码明细', 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) NOT NULL COMMENT 'Token绑定的海外仓库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), + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (dispatch_order_id), UNIQUE KEY uk_ll_dispatch_pda_task_no (task_no) -) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; +) 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', @@ -2089,8 +2195,10 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥', + dispatch_item_source_id BIGINT NULL COMMENT '换标实际来源贡献快照ID,与其他三类真实凭据互斥', KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), - CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1), + KEY idx_dispatch_pick_item_source(customer_id,dispatch_item_source_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) + (dispatch_item_source_id IS NOT NULL) = 1), PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), @@ -2116,13 +2224,77 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( KEY idx_ll_outbound_list (customer_id,status,created_at) ) ENGINE=InnoDB COMMENT='尾程出库单'; +CREATE TABLE IF NOT EXISTS ll_dispatch_command ( + id BIGINT NOT NULL COMMENT '持久命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '命令所属整车排车主单ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '命令所属真实排车业务号', + action VARCHAR(40) NOT NULL COMMENT '本次整车作业动作', + operator_id VARCHAR(128) NOT NULL COMMENT '原始可信操作主体ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '跨操作主体全局唯一的原始请求键', + record_version BIGINT NOT NULL COMMENT '原请求明确提交的排车版本,零为合法首版', + command_hash CHAR(64) NOT NULL COMMENT '绑定原主体动作业务号版本和完整载荷的严格摘要', + result_json LONGTEXT NOT NULL COMMENT '按动作类型恢复的不可变成功结果JSON', + created_at DATETIME(3) NOT NULL COMMENT '成功命令记录时间,使用UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_command_key (idempotency_key), + KEY idx_dispatch_command_history (dispatch_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车公共持久命令账本'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_delivery_arrangement ( + id BIGINT NOT NULL COMMENT '安排事实内部主键', + dispatch_order_id BIGINT NOT NULL COMMENT '唯一整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + arrangement_key VARCHAR(128) NOT NULL COMMENT '本次唯一安排业务事实标识', + business_hash CHAR(64) NOT NULL COMMENT '排除操作者及纯并发版本的安排业务摘要', + cargo_hash CHAR(64) NOT NULL COMMENT '本车实际拣货来源摘要', + loading_completion_hash CHAR(64) NOT NULL COMMENT '装车完成事实摘要', + original_command_key VARCHAR(255) NOT NULL COMMENT '首次安排的全局请求键', + original_operator_id VARCHAR(128) NOT NULL COMMENT '首次安排操作主体', + result_json JSON NOT NULL COMMENT '全部客户出库及客户目的地派送不可变结果', + cargo_json JSON NOT NULL COMMENT '逐客户逐拣货归属的不可变真实来源', + created_at DATETIME(3) NOT NULL COMMENT '本次安排实际创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_arrangement_dispatch (dispatch_order_id), + UNIQUE KEY uk_dispatch_arrangement_key (arrangement_key), + UNIQUE KEY uk_dispatch_arrangement_command (original_command_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车唯一派送安排事实'; + +CREATE TABLE IF NOT EXISTS ll_delivery_facts_changed ( + id BIGINT NOT NULL COMMENT '持久信号内部主键', + customer_id VARCHAR(128) NOT NULL COMMENT '真实签收货物客户ID', + dispatch_id BIGINT NOT NULL COMMENT '整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + delivery_id BIGINT NOT NULL COMMENT '本客户派送单ID', + delivery_no VARCHAR(128) NOT NULL COMMENT '真实派送业务号', + delivery_version BIGINT NOT NULL COMMENT '触发信号的真实派送版本', + facts_hash CHAR(64) NOT NULL COMMENT '该版本真实签收事实摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '签收事实实际发生时间', + state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '待核对PENDING或已核对PROCESSED', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已记录的汇总失败次数', + next_attempt_at DATETIME(3) NOT NULL COMMENT '下次可重试时间', + last_error_code VARCHAR(64) NULL COMMENT '最近失败诊断码不保存敏感异常正文', + processed_at DATETIME(3) NULL COMMENT '与主阶段汇总同事务确认时间', + PRIMARY KEY (id), + UNIQUE KEY uk_delivery_fact_version (customer_id,delivery_id,delivery_version), + KEY idx_delivery_fact_due (state,next_attempt_at,id), + KEY idx_delivery_fact_dispatch (dispatch_id,state,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正常签收事实变化的本地持久待汇总信号'; + CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + dispatch_item_id BIGINT NULL COMMENT '本次整车排车作业明细ID', + source_attribution_id BIGINT NULL COMMENT '不可变PDA真实拣货归属ID', + source_order_id BIGINT NULL COMMENT '本行真实来源尾程订单ID', + source_inventory_position_id BIGINT NULL COMMENT '本行实际拣货库存仓位ID', + source_return_receipt_id BIGINT NULL COMMENT '本行实际退回再派实收记录ID', + source_return_carton_id BIGINT NULL COMMENT '本行实际退货换标来源箱ID', + carton_marks VARCHAR(4000) NULL COMMENT '本行真实拣货箱唛快照', 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 '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米),来源未确认时为空', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', 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,inbound_code,sku_code), + UNIQUE KEY uk_outbound_source_attribution (customer_id,source_attribution_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='出库托盘明细'; diff --git a/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml index 8488475c3..12ee67e7b 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml @@ -1,32 +1,10 @@ openapi: 3.0.3 info: - title: 共用车次准备与拼车连续处理 + title: 外接登记与整车履约衔接 version: 1.0.0 security: - bearerAuth: [] paths: - /api/platform/v1/last-leg/fleet/trips/prepare: - post: - summary: 原子新建或加入车次并关联所选排车单 - description: '权限:platform:fleet:trip:edit;新建另需platform:fleet:trip:create和platform:fleet:trip:assign。新建、资源分配和全部关联在同一事务,失败回滚;幂等键同载荷重放原结果,异载荷拒绝。同仓、未执行、未绑定其他车次;不确认容量、不审批、不发车。' - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/Prepare'} - responses: - '200': {description: '统一ApiResponse,data为FleetTripModels.Detail;code=200才成功'} - /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatch-candidates: - parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/TripNo'}] - get: - summary: 本仓未关联且未开始执行的排车候选 - description: '权限:platform:fleet:trip:list。数据库筛选后分页,绑定时重新锁定校验。候选不代表预约、装载或审核已完成。' - parameters: - - {name: keyword, in: query, schema: {type: string}} - - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} - - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} - responses: - '200': {description: '统一分页data:records含dispatchNo、customerId、warehouseId、packageCount、route及total/page/size/pages;内部身份不作为用户业务编号'} /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/save-and-confirm: parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] post: @@ -74,7 +52,7 @@ paths: parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] post: summary: 实收后复用或生成排车并原子排入本车 - description: '权限:platform:fleet:capacity-offer:manage、platform:fleet:trip:booking-edit、platform:fleet:trip:edit和platform:overseas-warehouse:inbound-order:smart-dispatch。锁原车次和本票来货,先校验实收快照、车辆、有效容量;来源有多张排车或不完整时拒绝,不另建。无排车时复用真实预排命令;任何校验或绑定失败整体回滚。同键重放不重复建单。成功将预留替换为BOUND,旧计划失效待核对,仍需审核和现场作业。' + description: '权限:platform:fleet:capacity-offer:manage、platform:fleet:trip:booking-edit、platform:fleet:trip:edit和platform:overseas-warehouse:dispatch:create。核对本车唯一排车主单与本票真实客户来货,校验实收快照、车辆及有效容量;货物加入该唯一主单,不能另建或挂入第二张客户排车。校验或整车加货失败整体回滚。同键重放不重复建单。成功将预留替换为BOUND,旧计划失效待核对,仍需审核和现场作业。' requestBody: required: true content: @@ -85,19 +63,3 @@ paths: components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} - schemas: - Prepare: - type: object - required: [dispatchNos, idempotencyKey] - properties: - tripNo: {type: string, nullable: true, maxLength: 64} - recordVersion: {type: integer, format: int64, nullable: true, minimum: 0} - plan: - allOf: - - {$ref: './fleet-trips-v1.yaml#/components/schemas/Create'} - nullable: true - description: 仅新建提交,实际创建幂等键使用外层idempotencyKey。 - vehicleId: {type: string, nullable: true} - driverId: {type: string, nullable: true} - dispatchNos: {type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: {type: string, maxLength: 64}} - idempotencyKey: {type: string, minLength: 8, maxLength: 128} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index d19cadd9c..e693121eb 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -3,27 +3,16 @@ info: title: 平台运输车次与资源分配 version: 1.0.0 description: >- - 车次是一次实际运输的稳定业务编号,客户排车、库存和派送单继续保留各自客户归属。 - 本文件定义计划、资源分配及客户排车单关联命令;分配或关联不会扣库存或标记发车。 - 创建可暂不指定车与司机,幂等键按创建人隔离;相同键相同内容返回同一车次,不同内容拒绝。 - 正式分配要求在役海外车辆及已确认的托位/载重/容积,以及海外司机;司机按预计任务结束时间重新核验且验证证照文件。 - 发车预览与实际发车重新核对司机当前海外区域和身份;分配后档案变更不能沿用原分配时的资格结果。 - 一个车辆或司机同时只能占用一个未释放车次;当前不提供多个未来时段的并行预约。 - 改派先取得新资源,成功后释放旧资源;整次写入与历史处于同一事务。 - 司机预计结束包含承担的返程/转场;修改预计结束须重新核验司机,所有写命令使用recordVersion。 - 普通计划接口不提供修改发车或签收状态的入口;PDA统一发车,结案检查所有客户派送终态及实发数量。 - 货物结案不释放车辆或司机;结束各资源任务独立提交实际结束时间、地点和原因,车辆另确认可用/维修/停用/已归还状态。 - 结案和结束任务命令携带版本及幂等键;结束司机任务不要求其证照仍有效,也不自动恢复其派车资格。 - 关联要求已分配车和司机,客户排车单同起运仓且未开始拣货;可跨客户关联但不改变客户数据归属。 - 同一排车单唯一关联一个车次;关联、改派、解除及尚未执行的派送单车/司机快照同步使用同一事务。 - 关联后子排车不能独立编辑、取消或单独发车;取消车次前须解除关联,已开工不允许直接解除或变更路线。 - 发车前允许在车次统一换车、换司机或调整时间,保留全部拣货事实与客户归属;已确认装载须能容纳于替换车辆。 - 改派或调整计划使原装载确认失效,发车前须重新确认;不支持用计划接口处理在途换车或撤销已执行货物。 - 关联只建立执行单据关系,不代表实际装载量、容量核验或审核已完成。 - 每张客户排车单调度审核通过后允许新拣货;整车确认实际装载并通过理货审核后允许发车。 - 审核仅通过或打回,通过意见选填、打回原因必填;无需额外提交或不同审核人。 - 换车换司机、修改路线或时间使调度与理货审核待审;仅修改备注保留审核及装载。 - 关联变更、重新确认装载或重新审核客户排车单使理货审核待审;保留全部拣货事实及历史审核快照。 + 排车列表是一趟整车作业的唯一主入口,一张排车单对应唯一车次资源记录;同一起运仓允许多个客户和目的地。 + 整车主单与任务不属于单一客户,货物明细、扫码归属、出库和派送单分别保留真实客户边界。 + 本文件保留资源占用查询、历史详情、计划容量、外接预订、结案及车辆/司机任务结束等附加能力。 + 不提供独立派送车次创建、多排车单手工绑定、旧调度/理货审核或装车写入口;审核和装发使用排车主单接口。 + 已绑定排车主单的资源与路线调整由排车主单执行;零真实扫码时显式退回装车前,修改后重新两级审核。 + 车辆和司机资格、容量与实际占用由服务端复验;候选可用不表示已预留,临时外配证据依排车主单规则核验。 + 调度审核后经理货审核才可真实扫码;PDA拣货核平后,平台和PDA均可确认装车完成及实际发车。 + 发车前整车派送安排须完整,正常签收汇总推进排车派送完成;既有异常业务和附加资源能力保留。 + 货物结案不自动释放车辆或司机;各资源任务分别提交实际结束时间、地点和原因,车辆另确认结束状态。 + 版本、幂等、客户隔离及事务要求以各实际端点及排车公共契约为准;历史数量不授权当前分配。 security: - bearerAuth: [] paths: @@ -116,11 +105,6 @@ paths: size: {type: integer} total: {type: integer} pages: {type: integer} - post: - summary: 幂等创建车次计划 - description: '权限:platform:fleet:trip:create;起运仓使用公共有效海外仓目录;不推断GPS路线。' - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Create'}}}} - responses: {'200': {$ref: '#/components/responses/Detail'}} /api/platform/v1/last-leg/fleet/trips/customer-options: get: summary: 查询有效委托客户候选 @@ -221,22 +205,6 @@ paths: summary: 查询车次详情和当前司机资格 description: '权限:platform:fleet:trip:list;司机资格仅是详情预览,实际命令必须重新核验。' responses: {'200': {$ref: '#/components/responses/Detail'}} - /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/{dispatchNo}/review: - parameters: - - {$ref: '#/components/parameters/TripNo'} - - {name: dispatchNo, in: path, required: true, schema: {type: string}} - post: - summary: 通过或打回单张客户排车调度审核 - description: '权限:platform:fleet:trip:dispatch-review;仅PLANNED;审核所属车次内的排车单,成功后理货审核待审;审批不扣库存。' - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Review'}}}} - responses: {'200': {$ref: '#/components/responses/Detail'}, '409': {description: 版本、关联或资格冲突}} - /api/platform/v1/last-leg/fleet/trips/{tripNo}/loading-review: - parameters: [{$ref: '#/components/parameters/TripNo'}] - post: - summary: 通过或打回整车理货审核 - description: '权限:platform:fleet:trip:loading-review;仅PLANNED;通过要求全部调度审核、拣货和实际装载确认已完成,当前司机资格有效。' - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Review'}}}} - responses: {'200': {$ref: '#/components/responses/Detail'}, '409': {description: 版本、调度审核、装载或资格冲突}} /api/platform/v1/last-leg/fleet/trips/{tripNo}/plan: parameters: [{$ref: '#/components/parameters/TripNo'}] put: @@ -278,20 +246,6 @@ paths: recordVersion: {type: integer, format: int64, minimum: 0} reason: {type: string, minLength: 1, maxLength: 1000} responses: {'200': {$ref: '#/components/responses/Detail'}} - /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/bind: - parameters: [{$ref: '#/components/parameters/TripNo'}] - post: - summary: 将客户排车单关联到车次 - description: '权限:platform:fleet:trip:edit;按业务单号解析客户归属,同仓且未开工;关联、车/司机快照同步及按真实货物目的地标识补齐卸货站原子提交;目的地缺失拒绝关联,已有有效站点顺序与索引保留,初始默认占位站替换为真实目的地。' - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindingCommand'}}}} - responses: {'200': {$ref: '#/components/responses/Detail'}} - /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatches/unbind: - parameters: [{$ref: '#/components/parameters/TripNo'}] - post: - summary: 解除尚未执行的客户排车单关联 - description: '权限:platform:fleet:trip:edit;清除排车及派送单当前车辆司机快照,历史保留;已拣货禁止直接解除。' - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindingCommand'}}}} - responses: {'200': {$ref: '#/components/responses/Detail'}} /api/platform/v1/last-leg/fleet/trips/{tripNo}/history: parameters: [{$ref: '#/components/parameters/TripNo'}] get: @@ -324,12 +278,6 @@ paths: description: '权限:platform:fleet:trip:list;返回各客户货物组成、manifestHash和当前装载确认,实际重量不得使用计费材积重。' responses: '200': {description: 统一响应data为Loading, content: {application/json: {schema: {$ref: '#/components/schemas/LoadingEnvelope'}}}} - post: - summary: 确认整车实际装载及逐段容量 - description: '权限:platform:fleet:trip:edit;全部客户拣货核平且转派送后确认;同一实物托盘只计量一次。货物hash或车次版本变化拒绝,托盘卸货站须匹配原货物目的地。' - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmLoad'}}}} - responses: - '200': {description: 已确认装载快照,统一响应data为Loading, content: {application/json: {schema: {$ref: '#/components/schemas/LoadingEnvelope'}}}} /api/platform/v1/last-leg/fleet/trips/{tripNo}/completion: parameters: [{$ref: '#/components/parameters/TripNo'}] get: @@ -391,7 +339,7 @@ components: dispatches: {type: array, items: {$ref: '#/components/schemas/DispatchBinding'}} previousDispatches: type: array - description: 原绑定历史中已不在当前绑定的批次、客户及来源引用,按最新有效证据去重;历史数量不作为当前装载或可分配数量 + description: 原绑定历史中已不在当前绑定的批次及其客户、来源引用,按最新有效证据去重;历史数量不作为当前装载或可分配数量 items: {$ref: '#/components/schemas/PreviousDispatch'} appointmentHistoryUnavailableReason: type: string @@ -697,20 +645,6 @@ components: manifestHash: {type: string, pattern: '^[0-9a-f]{64}$'} pallets: {type: array, minItems: 1, maxItems: 1000, items: {$ref: '#/components/schemas/Measurement'}} reason: {type: string, minLength: 1, maxLength: 1000} - BindingCommand: - type: object - required: [recordVersion, dispatchNo, reason] - properties: - recordVersion: {type: integer, format: int64, minimum: 0} - dispatchNo: {type: string, minLength: 1, maxLength: 64} - reason: {type: string, minLength: 1, maxLength: 1000} - Review: - type: object - required: [recordVersion, decision] - properties: - recordVersion: {type: integer, format: int64, minimum: 0} - decision: {type: string, enum: [APPROVED, REJECTED]} - reason: {type: string, nullable: true, maxLength: 1000, description: 通过选填;打回必填} ReviewState: type: object required: [status] @@ -726,17 +660,15 @@ components: properties: appointmentBatches: {type: array, items: {$ref: 'overseas-business-v1.yaml#/components/schemas/DispatchBatchReference'}} dispatchNo: {type: string} - customerId: {type: string, description: 排车单自身客户归属,由服务端解析} packageCount: {type: integer, description: 关联时待发运箱数,不是实物托位数} boundBy: {type: string} boundAt: {type: string, format: date-time} review: {$ref: '#/components/schemas/ReviewState'} PreviousDispatch: type: object - required: [dispatchNo, customerId, recordVersion, recordedAt, appointmentBatches] + required: [dispatchNo, recordVersion, recordedAt, appointmentBatches] properties: dispatchNo: {type: string} - customerId: {type: string} recordVersion: {type: integer, format: int64, description: 原绑定历史的车次版本} recordedAt: {type: string, format: date-time} appointmentBatches: {type: array, items: {$ref: 'overseas-business-v1.yaml#/components/schemas/DispatchBatchReference'}} @@ -753,16 +685,6 @@ components: name: {type: string, minLength: 1, maxLength: 128} address: {type: string, nullable: true, maxLength: 500} destinationKey: {type: string, nullable: true, maxLength: 160, description: 卸货站匹配Loading货物的destinationKey;未配置时可以保存计划但不能确认装载} - Create: - type: object - required: [warehouseId, plannedDepartureAt, expectedDriverEndAt, stops, idempotencyKey] - properties: - warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} - plannedDepartureAt: {type: string, format: date-time} - expectedDriverEndAt: {type: string, format: date-time} - stops: {type: array, minItems: 2, maxItems: 200, items: {$ref: '#/components/schemas/Stop'}} - remark: {type: string, nullable: true, maxLength: 1000} - idempotencyKey: {type: string, minLength: 8, maxLength: 128} Plan: type: object required: [recordVersion, plannedDepartureAt, expectedDriverEndAt, stops, reason] diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 75f671884..76d732dd7 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -685,45 +685,192 @@ paths: '404': { description: 来源柜没有尾程订单 } /api/platform/v1/last-leg/dispatches: get: - summary: 分页查询排车单 - description: 权限 platform:overseas-warehouse:dispatch:list;平台跨客户分页查询,前端不传 customerId。 - security: [{ bearerAuth: [] }] - parameters: - - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } - - { name: dispatchNo, in: query, schema: { type: string } } - - { name: vehiclePlate, in: query, schema: { type: string } } - - { name: status, in: query, schema: { type: string } } - - { name: fbaAppointmentNo, in: query, schema: { type: string } } - - name: stage - in: query - description: 服务端在分页前筛选;BEFORE_LOADING=待发车未拣货,LOADING=拣货中或拣货完成待发车,AFTER_LOADING=已发车(含部分),CANCELLED=已取消;与status取交集。 - schema: { type: string, enum: [BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED] } + summary: 分页查询五阶段整车及全量筛选统计 + description: 权限 platform:overseas-warehouse:dispatch:list。客户仅为平台可选明细筛选,整车主单无客户头部;取消不是第六阶段。 + security: + - bearerAuth: [] responses: - '200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse } + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchPage' + nullable: true + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + - name: dispatchNo + in: query + schema: + type: string + - name: vehiclePlate + in: query + schema: + type: string + - name: warehouseId + in: query + schema: + type: string + - name: customerId + in: query + schema: + type: string + - name: fbaAppointmentNo + in: query + schema: + type: string + - name: status + in: query + schema: + $ref: '#/components/schemas/DispatchStatusFilter' + - name: stage + in: query + schema: + $ref: '#/components/schemas/DispatchStage' + - name: beforeLoadingSection + in: query + schema: + $ref: '#/components/schemas/DispatchBeforeLoadingSection' + - name: reviewStatus + in: query + schema: + $ref: '#/components/schemas/DispatchReviewStatus' + post: + summary: 新建一趟整车排车 + description: 权限 platform:overseas-warehouse:dispatch:create。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchCreate' /api/platform/v1/last-leg/dispatches/{dispatchNo}: get: - summary: 查询排车单详情 - description: 权限 platform:overseas-warehouse:dispatch:list;列表权限包含详情只读;平台按全局排车单号定位记录,并使用记录自身客户范围查询明细,前端不传 customerId。 - security: [{ bearerAuth: [] }] - parameters: - - { name: dispatchNo, in: path, required: true, schema: { type: string } } + summary: 读取四块排车详情 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] responses: - '200': { description: 统一 ApiResponse 包装的 DispatchResponse } - '404': { description: 排车单不存在 } + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchDetail' + nullable: true put: - summary: 编辑待发车排车单 - description: 权限 platform:overseas-warehouse:dispatch:edit;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId。仅允许 PENDING_DEPARTURE,且请求不包含 status/departAt,发车事实只能由 PDA 作业命令产生。已关联共享车次时拒绝独立编辑,须先按车次执行状态解除关联。 - security: [{ bearerAuth: [] }] - parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchUpdate' } } } } - responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单不存在 }, '409': { description: 排车单已执行或不可编辑 } } - delete: - summary: 取消待发车排车单 - description: 权限 platform:overseas-warehouse:dispatch:cancel;只按全局唯一 dispatchNo 锁定记录并解析客户范围,不接收 customerId。仅 PENDING_DEPARTURE 可取消并释放正式托盘锁;重复取消幂等成功。已关联共享车次必须先解除关联,不能单独取消破坏整车计划。 - security: [{ bearerAuth: [] }] - parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } } + summary: 按当前整车版本维护资源及计划 + description: 权限 platform:overseas-warehouse:dispatch:edit。更改影响审核依据时使两级审核失效;不手改阶段和实际发车时间。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchUpdate' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol: get: summary: 生成排车 BOL PDF @@ -741,184 +888,1248 @@ paths: '422': { description: 排车单没有可打印的 BOL 明细 } /api/platform/v1/last-leg/dispatches/candidates: get: - summary: 查询智能排车候选托盘 - description: 权限 platform:overseas-warehouse:inbound-order:list;planIds 为空时平台跨客户查询全部可排托盘,传入时按所选入库计划解析客户范围;仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。 - security: [{ bearerAuth: [] }] - parameters: - - name: planIds - in: query - required: false - description: 可选的入库计划 ID,逗号分隔;省略时平台查询全部客户候选。 - schema: { type: array, minItems: 1, maxItems: 500, items: { type: integer, format: int64 } } - style: form - explode: false - - { name: orderNo, in: query, schema: { type: string } } - - { name: fbaWarehouseCode, in: query, schema: { type: string } } - - { name: sourceStage, in: query, schema: { type: string, enum: [PLAN, RECEIVED] } } - - { name: draftToken, in: query, schema: { type: string, maxLength: 64 }, description: 当前智能排车草稿标识;当前草稿已锁托盘仍可见 } + summary: 读取真实候选及来源版本 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] responses: '200': - description: 统一 ApiResponse 包装的候选托盘数组 - content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchCandidate' } } } } - /api/platform/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}: - delete: - summary: 移除已排车托盘 - description: 权限 platform:overseas-warehouse:dispatch:edit;仅待发车、未拣货且尚未生成出库单时允许,成功后释放托盘锁并刷新排车汇总。已关联共享车次时须先解除关联。 - security: [{ bearerAuth: [] }] + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCandidateList' + nullable: true parameters: - - { name: dispatchNo, in: path, required: true, schema: { type: string } } - - { name: itemId, in: path, required: true, schema: { type: integer, format: int64 } } - responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单或托盘不存在 }, '409': { description: 已生成出库单或排车已开始执行 } } - /api/platform/v1/last-leg/dispatches/pre-arrange: - post: - summary: 创建智能预排车单 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围。首次推送创建 PENDING_DEPARTURE 排车单,未生成出库单前保留虚拟车草稿,再次推送刷新原排车单明细而不重复建单。 - security: [{ bearerAuth: [] }] - requestBody: + - name: warehouseId + in: query required: true - content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchPreArrange' } } } - responses: - '200': { description: 统一 ApiResponse 包装的预排车详情, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchResponse' } } } } + schema: + type: string + - name: planIds + in: query + schema: + type: array + items: + type: string + style: form + explode: true + - name: orderNo + in: query + schema: + type: string + - name: destinationWarehouseCode + in: query + schema: + type: string + - name: sourceStage + in: query + schema: + type: string + - name: draftToken + in: query + schema: + type: string /api/platform/v1/last-leg/dispatches/drafts/pre-arrange: post: - summary: 按装车分组原子创建多张预排车单 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;核对当前操作人的完整非空草稿分组、真实来源、唯一客户和海外仓、托位容量。每个装车容器生成独立排车单和PDA任务;任一组失败整批回滚。相同分组再次提交返回对应原单,不能按同一草稿合并不同车辆。 - security: [{ bearerAuth: [] }] + summary: 将所有非空容器原子转换为整车 + description: 权限 platform:overseas-warehouse:dispatch:create。本人草稿、单仓;一容器一整车,保留多客户和多目的地。原键重放返回原结果,任一组失败全批回滚。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchConvertedDraft' + nullable: true requestBody: required: true - content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchPreArrangeDraft' } } } - responses: - '200': { description: 统一ApiResponse包装的分组结果数组, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchDraftGroupResult' } } } } } - '409': { description: 草稿归属、来源、容量或分组已变化,未部分创建 } + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchConvertDraft' /api/platform/v1/last-leg/dispatches/draft-locks/lock: post: - summary: 将托盘移入智能排车草稿并立即锁定 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;草稿锁 30 分钟失效,与正式排车锁共享托盘排他约束。 - security: [{ bearerAuth: [] }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftPallet' } } } } - responses: { '200': { description: 锁定成功 }, '409': { description: 托盘已被其他草稿或正式排车锁定 } } + summary: 核对来源版本后锁入本草稿容器 + description: 权限 platform:overseas-warehouse:dispatch:create。本人草稿、单仓;一容器一整车,保留多客户和多目的地。原键重放返回原结果,任一组失败全批回滚。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchDraft' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchDraftSelection' /api/platform/v1/last-leg/dispatches/draft-locks/unlock: post: - summary: 从智能排车草稿移除托盘并立即解锁 - security: [{ bearerAuth: [] }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftPallet' } } } } - responses: { '200': { description: 解锁成功 }, '409': { description: 草稿锁不属于当前操作人 } } + summary: 解除本草稿本容器原来源占用 + description: 权限 platform:overseas-warehouse:dispatch:create。本人草稿、单仓;一容器一整车,保留多客户和多目的地。原键重放返回原结果,任一组失败全批回滚。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchDraft' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchDraftSelection' /api/platform/v1/last-leg/dispatches/draft-locks/release: post: - summary: 关闭智能排车草稿并释放草稿内全部托盘 - security: [{ bearerAuth: [] }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftRelease' } } } } - responses: { '200': { description: 草稿锁已全部释放 } } + summary: 按拥有者和版本释放草稿 + description: 权限 platform:overseas-warehouse:dispatch:create。本人草稿、单仓;一容器一整车,保留多客户和多目的地。原键重放返回原结果,任一组失败全批回滚。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchDraft' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchDraftRelease' /api/platform/v1/last-leg/dispatches/drafts: get: - summary: 查询当前操作人的有效智能排车草稿 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;只返回当前操作人未失效且未释放的草稿。 - security: [{ bearerAuth: [] }] + summary: 查询本人未失效的服务器草稿 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] responses: - '200': { description: 统一 ApiResponse 包装的智能排车草稿数组, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchDraft' } } } } } + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - type: array + items: + $ref: '#/components/schemas/DispatchDraft' + nullable: true + post: + summary: 持久幂等创建服务器草稿 + description: 权限 platform:overseas-warehouse:dispatch:create。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchDraft' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchCreateDraft' /api/platform/v1/last-leg/dispatches/drafts/snapshot: post: - summary: 保存智能排车草稿布局快照 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;草稿仅归当前操作人。 - security: [{ bearerAuth: [] }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftSnapshot' } } } } - responses: { '200': { description: 草稿快照保存成功 } } + summary: 保存本草稿已锁定来源的布局 + description: 权限 platform:overseas-warehouse:dispatch:create。本人草稿、单仓;一容器一整车,保留多客户和多目的地。原键重放返回原结果,任一组失败全批回滚。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchDraft' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchDraftSnapshot' /api/platform/v1/last-leg/dispatches/drafts/containers: post: - summary: 在智能排车草稿中新增车厢 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;返回服务端生成的字符串 ID。 - security: [{ bearerAuth: [] }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftContainerCreate' } } } } - responses: { '200': { description: 统一 ApiResponse 包装的草稿车厢, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftContainer' } } } } } + summary: 按草稿版本新增装车容器 + description: 权限 platform:overseas-warehouse:dispatch:create。本人草稿、单仓;一容器一整车,保留多客户和多目的地。原键重放返回原结果,任一组失败全批回滚。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchDraft' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchCreateContainer' /api/platform/v1/last-leg/dispatches/drafts/{draftToken}/containers/{containerId}: delete: - summary: 删除智能排车草稿中的空车厢 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;仅允许当前操作人删除未绑定托盘的草稿车厢。 - security: [{ bearerAuth: [] }] - parameters: - - { name: draftToken, in: path, required: true, schema: { type: string, maxLength: 64 } } - - { name: containerId, in: path, required: true, schema: { type: string } } - responses: { '200': { description: 空车厢删除成功 }, '409': { description: 车厢仍绑定托盘或草稿状态冲突 } } - /api/platform/v1/last-leg/dispatches/{dispatchNo}/fee-coverage: - get: - summary: 按实际发运来源查询原头程费用 - description: 权限platform:overseas-warehouse:dispatch:list;只接收排车号,客户由服务端解析。按实物库存与本次已拣数量还原各票来源并核平排车箱数;合托不使用单一汇总入仓码代替全部来源。sourceComplete=false时不返回费用候选。Finance批量查询仅返回各票该客户原入仓单的已审核有效应收最新版本,存在来源不等于任何服务已包含,查询不会免收或新增费用。 - security: [{ bearerAuth: [] }] - parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + summary: 按版本移除本人草稿的空容器 + description: 权限 platform:overseas-warehouse:dispatch:create。容器非空时拒绝;DELETE也须提交原版本和幂等请求体。 + security: + - bearerAuth: [] responses: '200': - description: 来源核对结果与原费用候选 + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 content: application/json: schema: type: object properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time data: - type: object - properties: - dispatchNo: { type: string } - sourceComplete: { type: boolean } - blockReason: { type: string, nullable: true } - cargoHash: { type: string } - cargo: - type: array - items: - type: object - properties: - dispatchItemId: { type: string } - warehousePalletId: { type: string } - palletNo: { type: string, nullable: true } - orderId: { type: string } - sourceRegion: { type: string, nullable: true } - sourceInboundId: { type: string, nullable: true } - inboundCode: { type: string, nullable: true } - packages: { type: integer } - dispatchInboundCode: { type: string, nullable: true, description: 排车来源业务码;不能代替实物拆分后的各票inboundCode } - feeSources: - type: array - items: - type: object - properties: - financeFeeId: { type: string } - sourceFeeId: { type: string } - sourceType: { type: string } - sourceVersion: { type: integer } - customerId: { type: string } - sourceInboundId: { type: string } - inboundCode: { type: string, nullable: true } - feeItemCode: { type: string } - feeItemName: { type: string } - currency: { type: string } - amount: { type: number } - sourceHash: { type: string } - status: { type: string, enum: [AVAILABLE, SETTLED] } - '404': { description: 排车单不存在 } - '503': { description: Finance来源查询不可用或返回范围不一致 } + allOf: + - $ref: '#/components/schemas/DispatchDraft' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchReasonCommand' + parameters: + - name: draftToken + in: path + required: true + schema: + type: string + - name: containerId + in: path + required: true + schema: + type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/external-freight: get: - summary: 读取本次排车的外接实收约定运费 - description: 权限 platform:overseas-warehouse:dispatch:list;客户从排车单解析。逐票核对真实来货、车次关联、目的地及实收数量,返回不可变报价版本与货物摘要;查询不发布费用。 - security: [{ bearerAuth: [] }] - parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + summary: 读取各客户目的地外接运费组 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] responses: '200': - description: 约定运费与是否仍需填写其余货物派送费用 + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 content: application/json: schema: type: object properties: - data: { $ref: '#/components/schemas/ExternalFreightPreview' } - '404': { description: 排车单不存在 } - '409': { description: 外接实收或排车关联不一致 } + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - type: array + items: + $ref: '#/components/schemas/DispatchPreviewGroup' + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery: post: - summary: 排车转出库与派送单 - description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;一个排车仍生成一张出库单,并按目的FBA仓分别生成派送单,返回 deliveries 数组;同一幂等键或已转换排车只有业务请求内容与首次保存一致才返回既有单据,费用、计划时间、装载或其他字段变化均拒绝,不因更换幂等键绕过。派送费用只发布一次,费用更正使用正式来源调整流程。已关联车次时新请求不得覆盖车次维护的车牌、司机和电话;原请求重放不改写后续已同步的车次快照。 - security: [{ bearerAuth: [] }] - parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } - responses: { '200': { description: 一张出库单与按目的FBA仓拆分的派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘、目的FBA仓或幂等冲突 } } + summary: 原子安排本车全部客户派送及费用 + description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchArrangeDelivery' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/items: + post: + summary: 按真实来源版本向整车加货 + description: 权限 platform:overseas-warehouse:dispatch:edit。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchAddCargo' + get: + summary: 读取真实明细及逐项移除资格 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchItems' + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/remove: + post: + summary: 移除本车未扫码的真实明细 + description: 权限 platform:overseas-warehouse:dispatch:edit。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchReasonCommand' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + - name: itemId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + /api/platform/v1/last-leg/dispatches/{dispatchNo}/cancel: + post: + summary: 按版本和原命令键取消排车 + description: 权限 platform:overseas-warehouse:dispatch:cancel。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchReasonCommand' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/submit-review: + post: + summary: 提交两级审核 + description: 权限 platform:overseas-warehouse:dispatch:edit。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchSubmitReview' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/dispatch-review: + post: + summary: 调度审核 + description: 权限 platform:fleet:trip:dispatch-review。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchReviewCommand' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-review: + post: + summary: 理货员审核 + description: 权限 platform:fleet:trip:loading-review。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchReviewCommand' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/return-to-review: + post: + summary: 零真实扫码退回装车前重审 + description: 权限 platform:overseas-warehouse:dispatch:edit。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchReturnToReview' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: + post: + summary: PDA核平后确认装车完成 + description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchLoadingComplete' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/route: + get: + summary: 读取实际起点与既有卸货站序 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchRoute' + nullable: true + put: + summary: 调整既有全部目的地卸货顺序 + description: 权限 platform:overseas-warehouse:dispatch:edit。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchRouteCommand' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading: + get: + summary: 读取真实拣货及实际测量依据 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchLoadingPreview' + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement: + get: + summary: 读取全车派送分组与费用依据 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchArrangementPreview' + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}: + get: + summary: 恢复本人原动作不可变结果 + description: 权限 原动作当前独立权限。必须为原操作者;服务端按持久动作重新核对权限,列表权限不授予他人命令读取。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + - name: idempotencyKey + in: path + required: true + schema: + type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}: + post: + summary: 上传本趟车辆或司机证据 + description: 权限 platform:overseas-warehouse:dispatch:edit。PDF/JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。上传后通过版本化EDIT保存引用。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchEvidenceFile' + nullable: true + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + - name: kind + in: path + required: true + schema: + type: string + enum: + - VEHICLE + - DRIVER + /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}: + get: + summary: 读取本车证据元数据 + description: 权限 platform:overseas-warehouse:dispatch:list。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchEvidenceFile' + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + - name: kind + in: path + required: true + schema: + type: string + enum: + - VEHICLE + - DRIVER + - name: fileId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}/content: + get: + summary: 鉴权读取本车证据内容 + description: 权限 platform:overseas-warehouse:dispatch:list;每次核对本车类型归属,inline且no-store;错误JSON不能当文件。 + security: + - bearerAuth: [] + responses: + '200': + description: PDF或图片内容;业务失败仍可能为统一JSON错误。 + content: + application/pdf: + schema: + type: string + format: binary + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + - name: kind + in: path + required: true + schema: + type: string + enum: + - VEHICLE + - DRIVER + - name: fileId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + /api/platform/v1/last-leg/dispatches/{dispatchNo}/depart: + post: + summary: 确认实际整车发车 + description: 权限 platform:overseas-warehouse:dispatch:depart。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchDepart' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/platform/v1/last-leg/outbound-orders: get: summary: 分页查询出库单 @@ -1284,13 +2495,6 @@ paths: - { name: plannedFrom, in: query, schema: { type: string, format: date-time } } - { name: plannedTo, in: query, schema: { type: string, format: date-time } } responses: { '200': { description: 当前客户派送单分页, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderPageResponse' } } } }, '403': { description: 缺少客户派送查看权限 } } - post: - summary: 客户从自己的排车单创建派送单 - description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。已关联车次时请求不得覆盖车次维护的车牌、司机和电话。 - security: [{ bearerAuth: [] }] - parameters: [{ name: dispatchNo, in: query, required: true, schema: { type: string, minLength: 1 } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } - responses: { '200': { description: 出库与派送单转换结果 }, '403': { description: 缺少客户派送创建权限 }, '404': { description: 当前客户下排车单不存在 }, '409': { description: 状态、参数或幂等冲突 } } /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}: get: summary: 客户查询自己的派送单详情 @@ -2805,189 +4009,41 @@ components: - type: object required: [data] properties: { data: { $ref: '#/components/schemas/ReceivingReceipt' } } - SmartDispatchCandidate: - type: object - properties: - planId: { type: string } - planPalletId: { type: string } - warehousePalletId: { type: string, nullable: true } - orderId: { type: string } - customerId: { type: string } - customerName: { type: string, nullable: true } - planNo: { type: string, nullable: true } - orderType: { type: string, nullable: true } - volumeWeightKg: { type: number, nullable: true } - grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重(千克);以实际毛重按入库计划分配比例汇总,缺失时为空,禁止使用体积重替代;正式配载须核验实际装载 } - containerNo: { type: string, nullable: true } - appointmentAt: { type: string, format: date-time, nullable: true } - expectedLoadingAt: { type: string, format: date-time, nullable: true } - overseasWarehouseId: { type: string } - overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码,用于智能排车区域筛选 } - overseasWarehouseName: { type: string } - orderNo: { type: string } - orderRefs: { type: string } - inboundCode: { type: string } - palletNo: { type: string, description: 未入库时为计划托盘码;绑定实物托盘后为 Warehouse 权威实物托盘码 } - destinationWarehouseId: { type: string } - destinationWarehouseCode: { type: string } - destinationWarehouseName: { type: string } - fbaWarehouseCode: { type: string } - packageCount: { type: integer } - volumeCbm: { type: number } - sourceStage: { type: string, enum: [PLAN, RECEIVED] } - sourceType: { type: string, enum: [INBOUND_PLAN, RETURN_RECEIPT], description: 正式实物来源类型 } - returnReceiptId: { type: string, nullable: true, description: 退回实收来源ID,不作为计划托盘ID } - returnReceiptNo: { type: string, nullable: true, description: 退回实收业务编号 } - cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } - SmartDispatchPreArrangeDraft: - type: object - required: [draftToken, groups] - properties: - draftToken: { type: string, maxLength: 64 } - groups: - type: array - minItems: 1 - maxItems: 100 - items: - type: object - required: [draftContainerId, planPalletIds, returnReceiptIds] - properties: - draftContainerId: { type: integer, format: int64 } - planPalletIds: { type: array, maxItems: 500, items: { type: integer, format: int64 } } - returnReceiptIds: { type: array, maxItems: 500, items: { type: integer, format: int64 } } - batchAllocations: { type: array, maxItems: 1000, items: { $ref: './appointment-work-v1.yaml#/components/schemas/DispatchBatchAllocation' } } - SmartDispatchDraftGroupResult: - type: object - required: [draftContainerId, dispatch] - properties: - draftContainerId: { type: integer, format: int64 } - dispatch: { $ref: '#/components/schemas/DispatchResponse' } - SmartDispatchPreArrange: - type: object - description: 普通来源使用planPalletIds或returnReceiptIds,合计至少一项且最多500项。指定relabelNo时planPalletIds必须是本换标申请全部已绑定实物托盘ID,普通与退货换标都不允许部分选择或混选其他来源;换标选择暂不持有统一草稿锁,仅提交时在申请锁内完整复核。退回来源仅接受实际入账生成的实收记录;同一实物托盘不可重复选择。 - properties: - relabelNo: { type: string, nullable: true, description: 整单换标申请业务号;不与退回实收来源混选 } - batchAllocations: - type: array - maxItems: 1000 - description: 已有预约批次的FBA实物必须逐票绑定,整托总量或可变预约号不能替代;发车再次校验实物与原预约。 - items: { $ref: './appointment-work-v1.yaml#/components/schemas/DispatchBatchAllocation' } - planPalletIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string } } - returnReceiptIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string }, description: 原订单实际退回入账来源ID } - fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } - fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } - vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } - draftToken: { type: string, nullable: true, maxLength: 64, description: 拖入托盘时使用的智能排车草稿标识 } - SmartDispatchDraftPallet: - type: object - required: [draftToken] - description: planPalletId和returnReceiptId恰有一个;退回来源必须声明sourceType=RETURN_RECEIPT。并发占用始终按客户及实物托盘锁定。 - properties: - planPalletId: { type: string } - returnReceiptId: { type: string } - sourceType: { type: string, enum: [INBOUND_PLAN, RETURN_RECEIPT] } - draftToken: { type: string, minLength: 1, maxLength: 64 } - draftContainerId: { type: string, nullable: true, description: 托盘所在草稿车厢ID;省略时复用唯一分组,无分组则新建30托默认组,多组时必须明确选择 } - SmartDispatchDraftRelease: - type: object - required: [draftToken] - properties: - draftToken: { type: string, minLength: 1, maxLength: 64 } - SmartDispatchDraftSnapshot: - type: object - required: [draftToken, planIds, containersJson] - properties: - draftToken: { type: string, minLength: 1, maxLength: 64 } - planIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } - containersJson: { type: string, minLength: 1, maxLength: 20000 } - SmartDispatchDraftContainerCreate: - type: object - required: [draftToken, capacity] - properties: - draftToken: { type: string, minLength: 1, maxLength: 64 } - capacity: { type: integer, enum: [15, 30] } - SmartDispatchDraft: - type: object - required: [draftToken, containers] - properties: - draftToken: { type: string } - planIdsJson: { type: string, nullable: true } - containersJson: { type: string, nullable: true } - containers: { type: array, items: { $ref: '#/components/schemas/SmartDispatchDraftContainer' } } - SmartDispatchDraftContainer: - type: object - required: [id, capacity, palletIds] - properties: - id: { type: string } - capacity: { type: integer } - palletIds: { type: array, items: { type: string } } DispatchUpdate: type: object - description: 普通编辑字段;不包含 status 或路线,路线由已排托盘关联的 FBA 仓自动汇总。 + x-java-record: DispatchCommands.Update properties: - vehiclePlate: { type: string, nullable: true, maxLength: 64 } - driverName: { type: string, nullable: true, maxLength: 128 } - driverPhone: { type: string, nullable: true, maxLength: 64 } - carrierName: { type: string, nullable: true, maxLength: 255 } - departAt: { type: string, format: date-time, nullable: true } - fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } - fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 } - vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 } - estimatedArrivalAt: { type: string, format: date-time, nullable: true } - DeliveryTransfer: - type: object - description: 外接货必须带入当前externalFreightHash,服务端按实收报价发布对应目的地的派送应收。平台可通过feeCoverage逐票确认原Finance费用所含服务,并传feeCoverageCargoHash;重新核对真实货物、费用ID/版本/hash及客户范围后冻结确认快照。只有全部相关货物已覆盖的服务才能免填对应新增应收,并禁止重复登记该应收;仍可独立登记供应商应付。未覆盖部分收费,原费用不复制入账也不伪造零金额费用。客户接口禁止自行声明feeCoverage。已有成功命令重放保留原覆盖快照。 - required: [idempotencyKey, plannedDeliveryAt, deliveryMethod, pickupMethod, loadingType, fees] - properties: - idempotencyKey: { type: string, minLength: 1, maxLength: 255 } - plannedDeliveryAt: { type: string, format: date-time } - deliveryMethod: { type: string, maxLength: 32 } - waybillNo: { type: string, nullable: true, maxLength: 128 } - carrierName: { type: string, nullable: true, maxLength: 255 } - pickupMethod: { type: string, maxLength: 64 } - loadingType: { type: string, maxLength: 64 } - vehicleType: { type: string, nullable: true, maxLength: 64 } - vehicleSize: { type: string, nullable: true, enum: [15托, 30托] } - driverName: { type: string, nullable: true, maxLength: 128 } - driverPhone: { type: string, nullable: true, maxLength: 64 } - vehiclePlate: { type: string, nullable: true, maxLength: 64 } - appointmentAccount: { type: string, nullable: true, maxLength: 128 } - sellerName: { type: string, nullable: true, maxLength: 128 } - remark: { type: string, nullable: true, maxLength: 1000 } - fees: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } - externalFreightHash: { type: string, nullable: true, maxLength: 64 } - feeCoverageCargoHash: { type: string, nullable: true, maxLength: 64 } - feeCoverage: { type: array, nullable: true, maxItems: 2000, items: { $ref: '#/components/schemas/FeeCoverageSelection' } } - FeeCoverageSelection: - type: object - required: [financeFeeId, sourceVersion, sourceHash, services] - properties: - financeFeeId: { type: string, pattern: '^[1-9][0-9]*$' } - sourceVersion: { type: integer, minimum: 1 } - sourceHash: { type: string } - services: { type: array, minItems: 1, maxItems: 3, items: { type: string, enum: [DELIVERY_FEE, LOADING_FEE, APPOINTMENT_FEE] } } - ExternalFreightPreview: - type: object - required: [snapshotHash, requiresDeliveryFee, quotes] - properties: - snapshotHash: { type: string } - requiresDeliveryFee: { type: boolean } - quotes: - type: array - items: - type: object - properties: - bookingNo: { type: string } - recordVersion: { type: integer, format: int64 } - incomingNo: { type: string } - inboundCode: { type: string, nullable: true } - customerName: { type: string } - destinationKey: { type: string } - currency: { type: string, enum: [CNY, USD] } - amount: { type: number } - quantity: { type: number } - quantityUnit: { type: string, enum: [托位, 单] } - unitPrice: { type: number } + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + vehicle: + allOf: + - $ref: '#/components/schemas/DispatchVehicle' + nullable: true + driver: + allOf: + - $ref: '#/components/schemas/DispatchDriver' + nullable: true + plannedDepartureAt: + type: string + format: date-time + nullable: true + estimatedArrivalAt: + type: string + format: date-time + nullable: true + remark: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey DeliveryStatusChange: type: object required: [status, expectedVersion] @@ -3123,121 +4179,89 @@ components: type: object required: [reason] properties: { reason: { type: string, minLength: 1, maxLength: 1000 } } - DispatchResponse: - type: object - required: [appointmentBatches, actions] - properties: - actions: { $ref: '#/components/schemas/DispatchActions' } - id: { type: string } - dispatchNo: { type: string } - vehiclePlate: { type: string, nullable: true } - driverName: { type: string, nullable: true } - driverPhone: { type: string, nullable: true } - carrierName: { type: string, nullable: true } - orderNos: { type: string } - route: { type: string, nullable: true } - overseasWarehouseId: { type: string } - overseasWarehouseName: { type: string } - departAt: { type: string, format: date-time, nullable: true } - appointmentBatches: - type: array - description: 当前排车分配中的全部原批次、客户及来源引用;无预约分配时为空,不通过旧预约号推导 - items: { $ref: '#/components/schemas/DispatchBatchReference' } - fbaAppointmentNo: { type: string, nullable: true } - fbaAppointmentStatus: { type: string } - vehicleAppointmentStatus: { type: string } - estimatedArrivalAt: { type: string, format: date-time, nullable: true } - status: { type: string, description: 预排新建固定为 PENDING_DEPARTURE;查询可返回历史排车状态 } - itemCount: { type: integer } - palletCount: { type: integer } - packageCount: { type: integer } - pickedPackageCount: { type: integer } - shippedPackageCount: { type: integer } - remainingPackageCount: { type: integer } - partialCompletionReason: { type: string, nullable: true } - createdAt: { type: string, format: date-time } - items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } } - workflow: - nullable: true - description: 平台查询返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。 - allOf: [{ $ref: '#/components/schemas/DispatchWorkflow' }] - bolLines: - type: array - description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大 - items: { $ref: '#/components/schemas/DispatchBolLine' } - DispatchActions: - type: object - required: [canEdit, canCancel, canRemovePallet, canTransfer] - description: 服务端按执行事实计算;入口写权限仍需单独校验。取消按解除车次、取消出库、取消原排车顺序;取消单据保留,转派送原请求重放返回原取消结果。 - properties: - canEdit: { type: boolean } - editBlockReason: { type: string, nullable: true } - canCancel: { type: boolean } - cancelBlockReason: { type: string, nullable: true } - canRemovePallet: { type: boolean } - removePalletBlockReason: { type: string, nullable: true } - canTransfer: { type: boolean } - transferBlockReason: { type: string, nullable: true } - outboundNo: { type: string, nullable: true } - outboundStatus: { type: string, nullable: true } DispatchBatchSource: type: object - required: [sourceKey, destinationKey, packageCount] + x-java-record: DispatchContracts.BatchSource properties: - sourceKey: { type: string } - destinationKey: { type: string } - packageCount: { type: integer, minimum: 1 } + sourceKey: + type: string + nullable: true + destinationKey: + type: string + nullable: true + packageCount: + type: integer + required: + - sourceKey + - destinationKey + - packageCount DispatchBatchReference: type: object - required: [batchKey, batchNo, customerId, sources] + x-java-record: DispatchContracts.BatchReference properties: - batchKey: { type: string, description: 原分配持久化的稳定批次键 } - batchNo: { type: string, nullable: true, description: 原批次业务号;缺失不以内码代替 } - customerId: { type: string } - sources: { type: array, items: { $ref: '#/components/schemas/DispatchBatchSource' } } - DispatchWorkflow: - type: object - required: [tripNo, dispatchReview, loadingReview] - properties: - tripNo: { type: string } - dispatchReview: { $ref: '#/components/schemas/DispatchReview' } - loadingReview: { $ref: '#/components/schemas/DispatchReview' } + batchKey: + type: string + nullable: true + batchNo: + type: string + nullable: true + customerId: + type: string + nullable: true + sources: + type: array + items: + $ref: '#/components/schemas/DispatchBatchSource' + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 所属batchKey的权威版本,必填且0合法;FBA取原预约版本,自动DISPATCH引用取真实主单版本。不能使用外层整车版本、客户端时间或默认0替代。Long响应保留十进制字符串。 + required: + - batchKey + - batchNo + - customerId + - sources + - recordVersion + description: 服务端权威批次引用,包含来源集合及所属批次原版本;显示预约号不能替代batchKey。 DispatchReview: type: object - required: [status] + x-java-record: DispatchContracts.Review properties: - status: { type: string, enum: [PENDING, APPROVED, REJECTED] } - reason: { type: string, nullable: true } - reviewedAt: { type: string, format: date-time, nullable: true } - DispatchItem: - type: object - properties: - id: { type: string } - planPalletId: { type: string } - warehousePalletId: { type: string, nullable: true } - palletNo: { type: string } - overseasWarehouseId: { type: string } - overseasWarehouseName: { type: string } - orderNo: { type: string } - orderRefs: { type: string } - inboundCode: { type: string } - destinationWarehouseCode: { type: string } - destinationWarehouseName: { type: string } - fbaWarehouseCode: { type: string } - packageCount: { type: integer } - volumeCbm: { type: number } - sourceStage: { type: string, enum: [PLAN, RECEIVED] } - DispatchBolLine: - type: object - properties: - containerNo: { type: string, nullable: true } - palletNo: { type: string, nullable: true } - fbaWarehouseCode: { type: string, nullable: true } - destinationWarehouseName: { type: string, nullable: true } - warehouseNo: { type: string, nullable: true, description: 来源入仓码/仓库单号 } - fbaShipmentId: { type: string, nullable: true, description: 来源冻结货件编号;多值以逗号分隔 } - poReferenceNo: { type: string, nullable: true, description: 来源冻结FBA跟踪/参考号;多值以逗号分隔 } - packageCount: { type: integer, minimum: 0 } + status: + allOf: + - $ref: '#/components/schemas/DispatchReviewStatus' + nullable: true + reason: + type: string + nullable: true + reviewerId: + type: string + nullable: true + reviewedAt: + type: string + format: date-time + nullable: true + reviewRound: + type: string + pattern: ^[0-9]+$ + basisHash: + type: string + nullable: true + invalidatedReason: + type: string + nullable: true + required: + - status + - reason + - reviewerId + - reviewedAt + - reviewRound + - basisHash + - invalidatedReason RelabelStatus: { type: string, enum: [PENDING_REVIEW, PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] } RelabelMerchantPageResponse: allOf: @@ -4132,5 +5156,2823 @@ components: pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 } deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 } remark: { type: string, nullable: true, maxLength: 1000 } + DispatchStatus: + type: string + enum: + - ACTIVE + - CANCELLED + DispatchStage: + type: string + enum: + - BEFORE_LOADING + - LOADING + - AFTER_LOADING + - DELIVERING + - DELIVERED + DispatchReviewState: + type: string + enum: + - DRAFT + - DISPATCH_PENDING + - DISPATCH_REJECTED + - LOADING_PENDING + - LOADING_REJECTED + - APPROVED + DispatchReviewStatus: + type: string + enum: + - NOT_SUBMITTED + - PENDING + - APPROVED + - REJECTED + DispatchDecision: + type: string + enum: + - APPROVED + - REJECTED + DispatchResourceMode: + type: string + enum: + - MASTER + - EXTERNAL + DispatchActorMode: + type: string + enum: + - PLATFORM + - PDA + DispatchSourceType: + type: string + enum: + - INBOUND_PLAN + - RETURN_RECEIPT + - RELABEL + DispatchBeforeLoadingSection: + type: string + enum: + - VEHICLE_INFO + - DISPATCH_REVIEW + - LOADING_REVIEW + DispatchStatusFilter: + type: string + enum: + - ACTIVE + - CANCELLED + - ALL + DispatchEffect: + type: string + enum: + - APPLIED + - EXISTING + DispatchAction: + type: string + enum: + - CREATE + - EDIT + - ADD_CARGO + - REMOVE_CARGO + - CANCEL + - SUBMIT_REVIEW + - DISPATCH_REVIEW + - LOADING_REVIEW + - RETURN_TO_REVIEW + - PICK + - LOADING_COMPLETE + - ARRANGE_DELIVERY + - DEPART + DispatchGate: + type: object + x-java-record: DispatchContracts.Gate + properties: + allowed: + type: boolean + blockCode: + type: string + nullable: true + blockReason: + type: string + nullable: true + required: + - allowed + - blockCode + - blockReason + DispatchCounts: + type: object + x-java-record: DispatchContracts.Counts + properties: + itemCount: + type: string + pattern: ^[0-9]+$ + palletCount: + type: string + pattern: ^[0-9]+$ + packageCount: + type: string + pattern: ^[0-9]+$ + pickedPackageCount: + type: string + pattern: ^[0-9]+$ + shippedPackageCount: + type: string + pattern: ^[0-9]+$ + required: + - itemCount + - palletCount + - packageCount + - pickedPackageCount + - shippedPackageCount + DispatchCapacity: + type: object + x-java-record: DispatchContracts.Capacity + properties: + palletSpaces: + type: integer + nullable: true + maxLoadKg: + type: number + nullable: true + maxVolumeCbm: + type: number + nullable: true + required: + - palletSpaces + - maxLoadKg + - maxVolumeCbm + DispatchDriverEvidence: + type: object + x-java-record: DispatchContracts.DriverEvidence + properties: + licenseNo: + type: string + nullable: true + licenseExpiresOn: + type: string + format: date + nullable: true + fileIds: + type: array + items: + type: string + pattern: ^[0-9]+$ + nullable: true + expectedTaskEndAt: + type: string + format: date-time + nullable: true + required: + - licenseNo + - licenseExpiresOn + - fileIds + - expectedTaskEndAt + description: 临时司机证照和预计任务结束依据,审核前核齐;文件绑定本趟。 + DispatchVehicleSnapshot: + type: object + x-java-record: DispatchContracts.VehicleSnapshot + properties: + mode: + allOf: + - $ref: '#/components/schemas/DispatchResourceMode' + nullable: true + vehicleId: + type: string + pattern: ^[0-9]+$ + nullable: true + plate: + type: string + nullable: true + carrierName: + type: string + nullable: true + vehicleStatus: + type: string + nullable: true + capacity: + allOf: + - $ref: '#/components/schemas/DispatchCapacity' + nullable: true + evidenceFileIds: + type: array + items: + type: string + pattern: ^[0-9]+$ + nullable: true + required: + - mode + - vehicleId + - plate + - carrierName + - vehicleStatus + - capacity + - evidenceFileIds + DispatchDriverSnapshot: + type: object + x-java-record: DispatchContracts.DriverSnapshot + properties: + mode: + allOf: + - $ref: '#/components/schemas/DispatchResourceMode' + nullable: true + driverId: + type: string + pattern: ^[0-9]+$ + nullable: true + name: + type: string + nullable: true + phone: + type: string + nullable: true + evidence: + allOf: + - $ref: '#/components/schemas/DispatchDriverEvidence' + nullable: true + required: + - mode + - driverId + - name + - phone + - evidence + DispatchResources: + type: object + x-java-record: DispatchContracts.Resources + properties: + vehicle: + allOf: + - $ref: '#/components/schemas/DispatchVehicleSnapshot' + nullable: true + driver: + allOf: + - $ref: '#/components/schemas/DispatchDriverSnapshot' + nullable: true + required: + - vehicle + - driver + DispatchDestination: + type: object + x-java-record: DispatchContracts.Destination + properties: + key: + type: string + nullable: true + type: + type: string + nullable: true + warehouseId: + type: string + nullable: true + warehouseCode: + type: string + nullable: true + warehouseName: + type: string + nullable: true + address: + type: string + nullable: true + postalCode: + type: string + nullable: true + contactName: + type: string + nullable: true + contactPhone: + type: string + nullable: true + required: + - key + - type + - warehouseId + - warehouseCode + - warehouseName + - address + - postalCode + - contactName + - contactPhone + DispatchReviews: + type: object + x-java-record: DispatchContracts.Reviews + properties: + dispatchReview: + allOf: + - $ref: '#/components/schemas/DispatchReview' + nullable: true + loadingReview: + allOf: + - $ref: '#/components/schemas/DispatchReview' + nullable: true + required: + - dispatchReview + - loadingReview + DispatchBatchAllocation: + type: object + x-java-record: DispatchContracts.BatchAllocation + properties: + planPalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + batchKey: + type: string + nullable: true + customerId: + type: string + nullable: true + sourceKey: + type: string + nullable: true + destinationKey: + type: string + nullable: true + packageCount: + type: integer + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 所属batchKey的权威版本,必填且0合法;FBA取原预约版本,自动DISPATCH引用取真实主单版本。不能使用外层整车版本、客户端时间或默认0替代。Long响应保留十进制字符串。 + required: + - planPalletId + - batchKey + - customerId + - sourceKey + - destinationKey + - packageCount + - recordVersion + description: 按真实客户、来源和目的地分配;原批次recordVersion随选择透传,锁后复核,不清空本车其他客户的原分配。 + DispatchCargoLine: + type: object + x-java-record: DispatchContracts.CargoLine + properties: + dispatchItemId: + type: string + pattern: ^[0-9]+$ + nullable: true + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + orderId: + type: string + pattern: ^[0-9]+$ + nullable: true + orderNo: + type: string + nullable: true + sourceKey: + type: string + nullable: true + sourceStage: + type: string + nullable: true + sourceRegion: + type: string + nullable: true + sourceInboundId: + type: string + nullable: true + inboundCode: + type: string + nullable: true + planPalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + warehousePalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + palletNo: + type: string + nullable: true + pickId: + type: string + pattern: ^[0-9]+$ + nullable: true + attributionId: + type: string + pattern: ^[0-9]+$ + nullable: true + inventoryPositionId: + type: string + pattern: ^[0-9]+$ + nullable: true + receiptId: + type: string + pattern: ^[0-9]+$ + nullable: true + returnReceiptId: + type: string + pattern: ^[0-9]+$ + nullable: true + returnCartonId: + type: string + pattern: ^[0-9]+$ + nullable: true + cartonMark: + type: string + nullable: true + relabelNo: + type: string + nullable: true + destination: + allOf: + - $ref: '#/components/schemas/DispatchDestination' + nullable: true + packages: + type: string + pattern: ^[0-9]+$ + productQuantity: + type: string + pattern: ^[0-9]+$ + grossWeightKg: + type: number + nullable: true + volumeCbm: + type: number + nullable: true + batchAllocations: + type: array + items: + $ref: '#/components/schemas/DispatchBatchAllocation' + nullable: true + sourceOrigin: + allOf: + - $ref: '#/components/schemas/DispatchSourceOrigin' + nullable: true + required: + - dispatchItemId + - customerId + - customerName + - orderId + - orderNo + - sourceKey + - sourceStage + - sourceRegion + - sourceInboundId + - inboundCode + - planPalletId + - warehousePalletId + - palletNo + - pickId + - attributionId + - inventoryPositionId + - receiptId + - returnReceiptId + - returnCartonId + - cartonMark + - relabelNo + - destination + - packages + - productQuantity + - grossWeightKg + - volumeCbm + - batchAllocations + - sourceOrigin + DispatchCommandResult: + type: object + x-java-record: DispatchContracts.CommandResult + properties: + dispatchNo: + type: string + nullable: true + action: + allOf: + - $ref: '#/components/schemas/DispatchAction' + nullable: true + idempotencyKey: + type: string + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + status: + allOf: + - $ref: '#/components/schemas/DispatchStatus' + nullable: true + stage: + allOf: + - $ref: '#/components/schemas/DispatchStage' + nullable: true + occurredAt: + type: string + format: date-time + nullable: true + effect: + allOf: + - $ref: '#/components/schemas/DispatchEffect' + nullable: true + counts: + allOf: + - $ref: '#/components/schemas/DispatchCounts' + nullable: true + result: + allOf: + - $ref: '#/components/schemas/DispatchPayload' + nullable: true + replayed: + type: boolean + alreadyArranged: + type: boolean + required: + - dispatchNo + - action + - idempotencyKey + - recordVersion + - status + - stage + - occurredAt + - effect + - counts + - result + - replayed + - alreadyArranged + description: 原主体、动作、排车号、版本和完整载荷绑定的不可变结果;replayed与alreadyArranged语义独立。读取当前详情另发GET。 + DispatchLoadingMeasurement: + type: object + x-java-record: DispatchContracts.LoadingMeasurement + properties: + warehousePalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + palletSpaces: + type: integer + weightKg: + type: number + nullable: true + volumeCbm: + type: number + nullable: true + deliveryIndex: + type: integer + required: + - warehousePalletId + - palletSpaces + - weightKg + - volumeCbm + - deliveryIndex + DispatchConfirmedLoad: + type: object + x-java-record: DispatchContracts.ConfirmedLoad + properties: + manifestHash: + type: string + nullable: true + pallets: + type: array + items: + $ref: '#/components/schemas/DispatchLoadingMeasurement' + nullable: true + operatorId: + type: string + nullable: true + confirmedAt: + type: string + format: date-time + nullable: true + required: + - manifestHash + - pallets + - operatorId + - confirmedAt + DispatchVehicle: + type: object + x-java-record: DispatchCommands.Vehicle + properties: + mode: + allOf: + - $ref: '#/components/schemas/DispatchResourceMode' + nullable: true + vehicleId: + type: string + pattern: ^[0-9]+$ + nullable: true + plate: + type: string + nullable: true + carrierName: + type: string + nullable: true + capacity: + allOf: + - $ref: '#/components/schemas/DispatchCapacity' + nullable: true + evidenceFileIds: + type: array + items: + type: string + pattern: ^[0-9]+$ + nullable: true + DispatchDriver: + type: object + x-java-record: DispatchCommands.Driver + properties: + mode: + allOf: + - $ref: '#/components/schemas/DispatchResourceMode' + nullable: true + driverId: + type: string + pattern: ^[0-9]+$ + nullable: true + name: + type: string + nullable: true + phone: + type: string + nullable: true + evidence: + allOf: + - $ref: '#/components/schemas/DispatchDriverEvidence' + nullable: true + DispatchCargoSelection: + type: object + x-java-record: DispatchCommands.CargoSelection + properties: + sourceType: + allOf: + - $ref: '#/components/schemas/DispatchSourceType' + planPalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + returnReceiptId: + type: string + pattern: ^[0-9]+$ + nullable: true + relabelNo: + type: string + nullable: true + warehousePalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + sourceVersion: + type: string + required: + - sourceType + - sourceVersion + DispatchCreate: + type: object + x-java-record: DispatchCommands.Create + properties: + idempotencyKey: + type: string + warehouseId: + type: string + vehicle: + allOf: + - $ref: '#/components/schemas/DispatchVehicle' + nullable: true + driver: + allOf: + - $ref: '#/components/schemas/DispatchDriver' + nullable: true + plannedDepartureAt: + type: string + format: date-time + nullable: true + estimatedArrivalAt: + type: string + format: date-time + nullable: true + remark: + type: string + nullable: true + cargo: + type: array + items: + $ref: '#/components/schemas/DispatchCargoSelection' + nullable: true + batchAllocations: + type: array + items: + $ref: '#/components/schemas/DispatchBatchAllocation' + nullable: true + required: + - idempotencyKey + - warehouseId + description: 同一起运仓,允许空单或多客户、多目的仓。客户和来源归属由服务端解析,临时资源不生成假档案。 + DispatchAddCargo: + type: object + x-java-record: DispatchCommands.AddCargo + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + cargo: + type: array + items: + $ref: '#/components/schemas/DispatchCargoSelection' + nullable: true + batchAllocations: + type: array + items: + $ref: '#/components/schemas/DispatchBatchAllocation' + nullable: true + required: + - recordVersion + - idempotencyKey + DispatchReasonCommand: + type: object + x-java-record: DispatchCommands.ReasonCommand + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + reason: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey + DispatchSubmitReview: + type: object + x-java-record: DispatchCommands.SubmitReview + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + remark: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey + DispatchReviewCommand: + type: object + x-java-record: DispatchCommands.ReviewCommand + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + decision: + allOf: + - $ref: '#/components/schemas/DispatchDecision' + nullable: true + reason: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey + DispatchReturnToReview: + type: object + x-java-record: DispatchCommands.ReturnToReview + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + reason: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey + DispatchPick: + type: object + x-java-record: DispatchCommands.Pick + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + positionId: + type: string + pattern: ^[0-9]+$ + nullable: true + packages: + type: string + pattern: ^[0-9]+$ + productQuantity: + type: string + pattern: ^[0-9]+$ + cartonMark: + type: string + nullable: true + remark: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey + DispatchLoadingComplete: + type: object + x-java-record: DispatchCommands.LoadingComplete + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + manifestHash: + type: string + nullable: true + measurements: + type: array + items: + $ref: '#/components/schemas/DispatchLoadingMeasurement' + nullable: true + remark: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey + description: 必须先由真实PDA拣货核平;这里只确认实载,不代填拣货箱数,不等于实际发车。 + DispatchDepart: + type: object + x-java-record: DispatchCommands.Depart + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + remark: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey + DispatchQuery: + type: object + x-java-record: DispatchViews.Query + properties: + page: + type: string + pattern: ^[0-9]+$ + size: + type: string + pattern: ^[0-9]+$ + dispatchNo: + type: string + nullable: true + vehiclePlate: + type: string + nullable: true + warehouseId: + type: string + nullable: true + customerId: + type: string + nullable: true + fbaAppointmentNo: + type: string + nullable: true + status: + allOf: + - $ref: '#/components/schemas/DispatchStatusFilter' + nullable: true + stage: + allOf: + - $ref: '#/components/schemas/DispatchStage' + nullable: true + beforeLoadingSection: + allOf: + - $ref: '#/components/schemas/DispatchBeforeLoadingSection' + nullable: true + reviewStatus: + allOf: + - $ref: '#/components/schemas/DispatchReviewStatus' + nullable: true + required: + - page + - size + - dispatchNo + - vehiclePlate + - warehouseId + - customerId + - fbaAppointmentNo + - status + - stage + - beforeLoadingSection + - reviewStatus + DispatchReviewCounts: + type: object + x-java-record: DispatchViews.ReviewCounts + properties: + notSubmitted: + type: string + pattern: ^[0-9]+$ + pending: + type: string + pattern: ^[0-9]+$ + approved: + type: string + pattern: ^[0-9]+$ + rejected: + type: string + pattern: ^[0-9]+$ + required: + - notSubmitted + - pending + - approved + - rejected + DispatchBeforeLoadingCounts: + type: object + x-java-record: DispatchViews.BeforeLoadingCounts + properties: + vehicleInfoTotal: + type: string + pattern: ^[0-9]+$ + dispatchReview: + allOf: + - $ref: '#/components/schemas/DispatchReviewCounts' + nullable: true + loadingReview: + allOf: + - $ref: '#/components/schemas/DispatchReviewCounts' + nullable: true + required: + - vehicleInfoTotal + - dispatchReview + - loadingReview + DispatchStatistics: + type: object + x-java-record: DispatchViews.Statistics + properties: + matchedTotal: + type: string + pattern: ^[0-9]+$ + activeTotal: + type: string + pattern: ^[0-9]+$ + cancelledTotal: + type: string + pattern: ^[0-9]+$ + stageCounts: + type: object + additionalProperties: + type: string + pattern: ^[0-9]+$ + nullable: true + beforeLoadingReviewCounts: + allOf: + - $ref: '#/components/schemas/DispatchBeforeLoadingCounts' + nullable: true + required: + - matchedTotal + - activeTotal + - cancelledTotal + - stageCounts + - beforeLoadingReviewCounts + DispatchPage: + type: object + x-java-record: DispatchViews.Page + properties: + records: + type: array + items: + $ref: '#/components/schemas/DispatchListRow' + nullable: true + total: + type: string + pattern: ^[0-9]+$ + page: + type: string + pattern: ^[0-9]+$ + size: + type: string + pattern: ^[0-9]+$ + pages: + type: string + pattern: ^[0-9]+$ + statistics: + allOf: + - $ref: '#/components/schemas/DispatchStatistics' + nullable: true + snapshotAt: + type: string + format: date-time + nullable: true + required: + - records + - total + - page + - size + - pages + - statistics + - snapshotAt + DispatchListRow: + type: object + x-java-record: DispatchViews.ListRow + properties: + dispatchNo: + type: string + nullable: true + status: + allOf: + - $ref: '#/components/schemas/DispatchStatus' + nullable: true + stage: + allOf: + - $ref: '#/components/schemas/DispatchStage' + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + reviewState: + allOf: + - $ref: '#/components/schemas/DispatchReviewState' + nullable: true + reviews: + allOf: + - $ref: '#/components/schemas/DispatchReviews' + nullable: true + resources: + allOf: + - $ref: '#/components/schemas/DispatchResources' + nullable: true + warehouseId: + type: string + nullable: true + warehouseCode: + type: string + nullable: true + warehouseName: + type: string + nullable: true + plannedDepartureAt: + type: string + format: date-time + nullable: true + departedAt: + type: string + format: date-time + nullable: true + counts: + allOf: + - $ref: '#/components/schemas/DispatchCounts' + nullable: true + pickingReconciled: + type: boolean + arranged: + type: boolean + actions: + type: object + additionalProperties: + $ref: '#/components/schemas/DispatchGate' + nullable: true + required: + - dispatchNo + - status + - stage + - recordVersion + - reviewState + - reviews + - resources + - warehouseId + - warehouseCode + - warehouseName + - plannedDepartureAt + - departedAt + - counts + - pickingReconciled + - arranged + - actions + DispatchVehicleInfo: + type: object + x-java-record: DispatchViews.VehicleInfo + properties: + vehicle: + allOf: + - $ref: '#/components/schemas/DispatchVehicleSnapshot' + nullable: true + driver: + allOf: + - $ref: '#/components/schemas/DispatchDriverSnapshot' + nullable: true + required: + - vehicle + - driver + DispatchFbaAppointment: + type: object + x-java-record: DispatchViews.FbaAppointment + properties: + batchKey: + type: string + nullable: true + batchNo: + type: string + nullable: true + appointmentNo: + type: string + nullable: true + destinationKey: + type: string + nullable: true + destinationWarehouseCode: + type: string + nullable: true + status: + type: string + nullable: true + confirmedAt: + type: string + format: date-time + nullable: true + blockReason: + type: string + nullable: true + required: + - batchKey + - batchNo + - appointmentNo + - destinationKey + - destinationWarehouseCode + - status + - confirmedAt + - blockReason + DispatchDispatchInfo: + type: object + x-java-record: DispatchViews.DispatchInfo + properties: + route: + type: string + nullable: true + plannedDepartureAt: + type: string + format: date-time + nullable: true + departedAt: + type: string + format: date-time + nullable: true + estimatedArrivalAt: + type: string + format: date-time + nullable: true + fbaAppointments: + type: array + items: + $ref: '#/components/schemas/DispatchFbaAppointment' + nullable: true + appointmentBatches: + type: array + items: + $ref: '#/components/schemas/DispatchBatchReference' + nullable: true + required: + - route + - plannedDepartureAt + - departedAt + - estimatedArrivalAt + - fbaAppointments + - appointmentBatches + DispatchDocuments: + type: object + x-java-record: DispatchViews.Documents + properties: + dispatchNo: + type: string + nullable: true + outbounds: + type: array + items: + $ref: '#/components/schemas/DispatchOutboundReference' + nullable: true + deliveries: + type: array + items: + $ref: '#/components/schemas/DispatchDeliveryReference' + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + remark: + type: string + nullable: true + required: + - dispatchNo + - outbounds + - deliveries + - createdAt + - updatedAt + - remark + DispatchOrderDetail: + type: object + x-java-record: DispatchViews.OrderDetail + properties: + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + orderNo: + type: string + nullable: true + inboundCode: + type: string + nullable: true + grossWeightKg: + type: number + nullable: true + palletCount: + type: integer + nullable: true + destinationFbaWarehouse: + type: string + nullable: true + volumeCbm: + type: number + nullable: true + containerNo: + type: string + nullable: true + sourceKey: + type: string + nullable: true + sourceBusinessType: + type: string + nullable: true + sourceBusinessNo: + type: string + nullable: true + required: + - customerId + - customerName + - orderNo + - inboundCode + - grossWeightKg + - palletCount + - destinationFbaWarehouse + - volumeCbm + - containerNo + - sourceKey + - sourceBusinessType + - sourceBusinessNo + DispatchDetail: + type: object + x-java-record: DispatchViews.Detail + properties: + dispatchNo: + type: string + nullable: true + status: + allOf: + - $ref: '#/components/schemas/DispatchStatus' + nullable: true + stage: + allOf: + - $ref: '#/components/schemas/DispatchStage' + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + reviewState: + allOf: + - $ref: '#/components/schemas/DispatchReviewState' + nullable: true + reviews: + allOf: + - $ref: '#/components/schemas/DispatchReviews' + nullable: true + counts: + allOf: + - $ref: '#/components/schemas/DispatchCounts' + nullable: true + pickingReconciled: + type: boolean + actions: + type: object + additionalProperties: + $ref: '#/components/schemas/DispatchGate' + nullable: true + vehicleInfo: + allOf: + - $ref: '#/components/schemas/DispatchVehicleInfo' + nullable: true + dispatchInfo: + allOf: + - $ref: '#/components/schemas/DispatchDispatchInfo' + nullable: true + documents: + allOf: + - $ref: '#/components/schemas/DispatchDocuments' + nullable: true + orderDetails: + type: array + items: + $ref: '#/components/schemas/DispatchOrderDetail' + nullable: true + required: + - dispatchNo + - status + - stage + - recordVersion + - reviewState + - reviews + - counts + - pickingReconciled + - actions + - vehicleInfo + - dispatchInfo + - documents + - orderDetails + description: 页头与动作资格之外只含车辆信息、排车信息、单据信息、关联订单明细四块。 + DispatchPdaTask: + type: object + x-java-record: DispatchViews.PdaTask + properties: + taskNo: + type: string + nullable: true + dispatchNo: + type: string + nullable: true + warehouseId: + type: string + nullable: true + status: + allOf: + - $ref: '#/components/schemas/DispatchStatus' + nullable: true + stage: + allOf: + - $ref: '#/components/schemas/DispatchStage' + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + reviews: + allOf: + - $ref: '#/components/schemas/DispatchReviews' + nullable: true + counts: + allOf: + - $ref: '#/components/schemas/DispatchCounts' + nullable: true + pickingReconciled: + type: boolean + items: + type: array + items: + $ref: '#/components/schemas/DispatchPdaItem' + nullable: true + actions: + type: object + additionalProperties: + $ref: '#/components/schemas/DispatchGate' + nullable: true + required: + - taskNo + - dispatchNo + - warehouseId + - status + - stage + - recordVersion + - reviews + - counts + - pickingReconciled + - items + - actions + DispatchPdaItem: + type: object + x-java-record: DispatchViews.PdaItem + properties: + dispatchItemId: + type: string + pattern: ^[0-9]+$ + nullable: true + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + palletNo: + type: string + nullable: true + destination: + allOf: + - $ref: '#/components/schemas/DispatchDestination' + nullable: true + scheduledPackages: + type: string + pattern: ^[0-9]+$ + pickedPackages: + type: string + pattern: ^[0-9]+$ + positions: + type: array + items: + $ref: '#/components/schemas/DispatchPosition' + nullable: true + returnCartons: + type: array + items: + $ref: '#/components/schemas/DispatchReturnCarton' + nullable: true + sourceKey: + type: string + sourceBusinessType: + type: string + nullable: true + sourceBusinessNo: + type: string + nullable: true + required: + - dispatchItemId + - customerId + - customerName + - palletNo + - destination + - scheduledPackages + - pickedPackages + - positions + - returnCartons + - sourceKey + - sourceBusinessType + - sourceBusinessNo + DispatchPosition: + type: object + x-java-record: DispatchViews.Position + properties: + positionId: + type: string + pattern: ^[0-9]+$ + nullable: true + balanceId: + type: string + pattern: ^[0-9]+$ + nullable: true + availablePackages: + type: string + pattern: ^[0-9]+$ + allocatedPackages: + type: string + pattern: ^[0-9]+$ + pickedPackages: + type: string + pattern: ^[0-9]+$ + required: + - positionId + - balanceId + - availablePackages + - allocatedPackages + - pickedPackages + DispatchReturnCarton: + type: object + x-java-record: DispatchViews.ReturnCarton + properties: + returnCartonId: + type: string + pattern: ^[0-9]+$ + nullable: true + cartonMark: + type: string + nullable: true + status: + type: string + nullable: true + productQuantity: + type: string + pattern: ^[0-9]+$ + required: + - returnCartonId + - cartonMark + - status + - productQuantity + DispatchCargoItem: + type: object + x-java-record: DispatchWorkModels.CargoItem + properties: + dispatchItemId: + type: string + pattern: ^[0-9]+$ + nullable: true + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + sourceKey: + type: string + nullable: true + sourceVersion: + type: string + nullable: true + sourceType: + allOf: + - $ref: '#/components/schemas/DispatchSourceType' + nullable: true + planPalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + returnReceiptId: + type: string + pattern: ^[0-9]+$ + nullable: true + relabelNo: + type: string + nullable: true + warehousePalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + palletNo: + type: string + nullable: true + orderNo: + type: string + nullable: true + inboundCode: + type: string + nullable: true + destination: + allOf: + - $ref: '#/components/schemas/DispatchDestination' + nullable: true + scheduledPackages: + type: string + pattern: ^[0-9]+$ + pickedPackages: + type: string + pattern: ^[0-9]+$ + remove: + allOf: + - $ref: '#/components/schemas/DispatchGate' + nullable: true + DispatchItems: + type: object + x-java-record: DispatchWorkModels.Items + properties: + dispatchNo: + type: string + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + status: + allOf: + - $ref: '#/components/schemas/DispatchStatus' + nullable: true + stage: + allOf: + - $ref: '#/components/schemas/DispatchStage' + nullable: true + items: + type: array + items: + $ref: '#/components/schemas/DispatchCargoItem' + nullable: true + required: + - recordVersion + DispatchStop: + type: object + x-java-record: DispatchWorkModels.Stop + properties: + index: + type: integer + name: + type: string + nullable: true + address: + type: string + nullable: true + destinationKey: + type: string + nullable: true + DispatchRoute: + type: object + x-java-record: DispatchWorkModels.Route + properties: + dispatchNo: + type: string + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + stops: + type: array + items: + $ref: '#/components/schemas/DispatchStop' + nullable: true + edit: + allOf: + - $ref: '#/components/schemas/DispatchGate' + nullable: true + required: + - recordVersion + DispatchRouteCommand: + type: object + x-java-record: DispatchWorkModels.RouteCommand + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + destinationKeys: + type: array + items: + type: string + nullable: true + reason: + type: string + nullable: true + required: + - recordVersion + - idempotencyKey + description: 只对既有全部目的地重排,保留外接目的地;原因必填,改变后两级审核失效。 + DispatchCandidateQuery: + type: object + x-java-record: DispatchPlanningModels.CandidateQuery + properties: + warehouseId: + type: string + nullable: true + planIds: + type: array + items: + type: string + pattern: ^[0-9]+$ + nullable: true + orderNo: + type: string + nullable: true + destinationWarehouseCode: + type: string + nullable: true + sourceStage: + type: string + nullable: true + draftToken: + type: string + nullable: true + DispatchCandidate: + type: object + x-java-record: DispatchPlanningModels.Candidate + properties: + selection: + allOf: + - $ref: '#/components/schemas/DispatchCargoSelection' + nullable: true + sourceKey: + type: string + nullable: true + sourceVersion: + type: string + nullable: true + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + warehouseId: + type: string + nullable: true + warehouseCode: + type: string + nullable: true + warehouseName: + type: string + nullable: true + orderNo: + type: string + nullable: true + inboundCode: + type: string + nullable: true + planNo: + type: string + nullable: true + palletNo: + type: string + nullable: true + containerNo: + type: string + nullable: true + destination: + allOf: + - $ref: '#/components/schemas/DispatchDestination' + nullable: true + availablePackages: + type: string + pattern: ^[0-9]+$ + suggestedWeightKg: + type: number + nullable: true + suggestedVolumeCbm: + type: number + nullable: true + measurementSource: + type: string + nullable: true + appointmentBatches: + type: array + items: + $ref: '#/components/schemas/DispatchBatchReference' + nullable: true + blockReasons: + type: array + items: + type: string + nullable: true + selectionGroup: + allOf: + - $ref: '#/components/schemas/DispatchSelectionGroup' + required: + - selectionGroup + DispatchCandidateList: + type: object + x-java-record: DispatchPlanningModels.CandidateList + properties: + candidates: + type: array + items: + $ref: '#/components/schemas/DispatchCandidate' + nullable: true + snapshotAt: + type: string + format: date-time + nullable: true + complete: + type: boolean + DispatchDraftContainer: + type: object + x-java-record: DispatchPlanningModels.DraftContainer + properties: + draftContainerId: + type: string + pattern: ^[0-9]+$ + nullable: true + sequenceNo: + type: integer + capacity: + type: integer + sourceKeys: + type: array + items: + type: string + nullable: true + DispatchDraft: + type: object + x-java-record: DispatchPlanningModels.Draft + properties: + draftToken: + type: string + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + status: + type: string + nullable: true + warehouseId: + type: string + nullable: true + containers: + type: array + items: + $ref: '#/components/schemas/DispatchDraftContainer' + nullable: true + sources: + type: array + items: + $ref: '#/components/schemas/DispatchCandidate' + nullable: true + expiresAt: + type: string + format: date-time + nullable: true + required: + - recordVersion + DispatchCreateDraft: + type: object + x-java-record: DispatchPlanningModels.CreateDraft + properties: + idempotencyKey: + type: string + warehouseId: + type: string + required: + - idempotencyKey + - warehouseId + DispatchCreateContainer: + type: object + x-java-record: DispatchPlanningModels.CreateContainer + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + draftToken: + type: string + capacity: + type: integer + required: + - recordVersion + - idempotencyKey + - draftToken + - capacity + DispatchDraftSelection: + type: object + x-java-record: DispatchPlanningModels.DraftSelection + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + draftToken: + type: string + draftContainerId: + type: string + pattern: ^[0-9]+$ + source: + allOf: + - $ref: '#/components/schemas/DispatchCargoSelection' + required: + - recordVersion + - idempotencyKey + - draftToken + - draftContainerId + - source + DispatchDraftSnapshot: + type: object + x-java-record: DispatchPlanningModels.DraftSnapshot + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + draftToken: + type: string + planIds: + type: array + items: + type: string + pattern: ^[0-9]+$ + containers: + type: array + items: + $ref: '#/components/schemas/DispatchDraftContainer' + required: + - recordVersion + - idempotencyKey + - draftToken + - planIds + - containers + DispatchDraftRelease: + type: object + x-java-record: DispatchPlanningModels.DraftRelease + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + draftToken: + type: string + required: + - recordVersion + - idempotencyKey + - draftToken + DispatchDraftVehicle: + type: object + x-java-record: DispatchPlanningModels.DraftVehicle + properties: + draftContainerId: + type: string + pattern: ^[0-9]+$ + nullable: true + dispatch: + allOf: + - $ref: '#/components/schemas/DispatchCreate' + nullable: true + DispatchConvertDraft: + type: object + x-java-record: DispatchPlanningModels.ConvertDraft + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + draftToken: + type: string + groups: + type: array + items: + $ref: '#/components/schemas/DispatchDraftVehicle' + required: + - recordVersion + - idempotencyKey + - draftToken + - groups + DispatchConvertedVehicle: + type: object + x-java-record: DispatchPlanningModels.ConvertedVehicle + properties: + draftContainerId: + type: string + pattern: ^[0-9]+$ + nullable: true + dispatch: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + DispatchConvertedDraft: + type: object + x-java-record: DispatchPlanningModels.ConvertedDraft + properties: + draftToken: + type: string + nullable: true + vehicles: + type: array + items: + $ref: '#/components/schemas/DispatchConvertedVehicle' + nullable: true + replayed: + type: boolean + DispatchMeasurementEvidence: + type: object + x-java-record: DispatchPlanningModels.MeasurementEvidence + properties: + field: + type: string + nullable: true + sourceType: + type: string + nullable: true + sourceBusinessNo: + type: string + nullable: true + observedAt: + type: string + format: date-time + nullable: true + value: + type: number + nullable: true + actual: + type: boolean + DispatchLoadingPallet: + type: object + x-java-record: DispatchPlanningModels.LoadingPallet + properties: + warehousePalletId: + type: string + pattern: ^[0-9]+$ + nullable: true + palletNo: + type: string + nullable: true + scheduledPackages: + type: string + pattern: ^[0-9]+$ + pickedPackages: + type: string + pattern: ^[0-9]+$ + palletSpaces: + type: integer + nullable: true + weightKg: + type: number + nullable: true + volumeCbm: + type: number + nullable: true + deliveryIndex: + type: integer + nullable: true + missingFields: + type: array + items: + type: string + nullable: true + evidence: + type: array + items: + $ref: '#/components/schemas/DispatchMeasurementEvidence' + nullable: true + DispatchLoadingPreview: + type: object + x-java-record: DispatchPlanningModels.LoadingPreview + properties: + dispatchNo: + type: string + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + status: + allOf: + - $ref: '#/components/schemas/DispatchStatus' + nullable: true + stage: + allOf: + - $ref: '#/components/schemas/DispatchStage' + nullable: true + manifestHash: + type: string + nullable: true + pickingReconciled: + type: boolean + resources: + allOf: + - $ref: '#/components/schemas/DispatchResources' + nullable: true + pallets: + type: array + items: + $ref: '#/components/schemas/DispatchLoadingPallet' + nullable: true + confirmedLoad: + allOf: + - $ref: '#/components/schemas/DispatchConfirmedLoad' + nullable: true + loadingComplete: + allOf: + - $ref: '#/components/schemas/DispatchGate' + nullable: true + missingFields: + type: array + items: + type: string + nullable: true + required: + - recordVersion + DispatchCoverageSelection: + type: object + x-java-record: DispatchDeliveryModels.CoverageSelection + properties: + financeFeeId: + type: string + pattern: ^[0-9]+$ + nullable: true + sourceVersion: + type: integer + sourceHash: + type: string + nullable: true + services: + type: array + items: + type: string + nullable: true + DispatchFeeItem: + type: object + x-java-record: DispatchDeliveryModels.FeeItem + properties: + feeType: + type: string + nullable: true + feeName: + type: string + nullable: true + direction: + type: string + nullable: true + currency: + type: string + nullable: true + amount: + type: number + nullable: true + supplierId: + type: string + pattern: ^[0-9]+$ + nullable: true + supplierName: + type: string + nullable: true + supplierCategory: + type: string + nullable: true + settlementMode: + type: string + nullable: true + paidAt: + type: string + format: date-time + nullable: true + paymentMethod: + type: string + nullable: true + paymentReferenceNo: + type: string + nullable: true + paymentVoucherFileIds: + type: array + items: + type: string + nullable: true + chargeableQuantity: + type: number + nullable: true + quantityUnit: + type: string + nullable: true + unitPrice: + type: number + nullable: true + periodStart: + type: string + format: date + nullable: true + periodEnd: + type: string + format: date + nullable: true + remark: + type: string + nullable: true + DispatchArrangeGroup: + type: object + x-java-record: DispatchDeliveryModels.ArrangeGroup + properties: + groupKey: + type: string + nullable: true + groupCargoHash: + type: string + nullable: true + plannedDeliveryAt: + type: string + format: date-time + nullable: true + deliveryMethod: + type: string + nullable: true + waybillNo: + type: string + nullable: true + carrierName: + type: string + nullable: true + pickupMethod: + type: string + nullable: true + loadingType: + type: string + nullable: true + vehicleType: + type: string + nullable: true + vehicleSize: + type: string + nullable: true + appointmentAccount: + type: string + nullable: true + sellerName: + type: string + nullable: true + remark: + type: string + nullable: true + externalFreightHash: + type: string + nullable: true + feeCoverageCargoHash: + type: string + nullable: true + feeCoverage: + type: array + items: + $ref: '#/components/schemas/DispatchCoverageSelection' + nullable: true + fees: + type: array + items: + $ref: '#/components/schemas/DispatchFeeItem' + nullable: true + DispatchArrangeDelivery: + type: object + x-java-record: DispatchDeliveryModels.ArrangeDelivery + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + idempotencyKey: + type: string + cargoHash: + type: string + nullable: true + loadingCompletionHash: + type: string + nullable: true + groups: + type: array + items: + $ref: '#/components/schemas/DispatchArrangeGroup' + nullable: true + required: + - recordVersion + - idempotencyKey + description: 全车原子安排;每客户出库,每客户目的仓派送。包含全部组,费用和来源依据重新校验,不重复库存SHIP。 + DispatchOutboundReference: + type: object + x-java-record: DispatchDeliveryModels.OutboundReference + properties: + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + outboundNo: + type: string + nullable: true + status: + type: string + nullable: true + packageCount: + type: string + pattern: ^[0-9]+$ + palletCount: + type: string + pattern: ^[0-9]+$ + DispatchDeliveryReference: + type: object + x-java-record: DispatchDeliveryModels.DeliveryReference + properties: + groupKey: + type: string + nullable: true + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + outboundNo: + type: string + nullable: true + deliveryNo: + type: string + nullable: true + destination: + allOf: + - $ref: '#/components/schemas/DispatchDestination' + nullable: true + status: + type: string + nullable: true + packageCount: + type: string + pattern: ^[0-9]+$ + palletCount: + type: string + pattern: ^[0-9]+$ + DispatchArrangementResult: + type: object + x-java-record: DispatchDeliveryModels.ArrangementResult + properties: + dispatchNo: + type: string + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + arrangementKey: + type: string + nullable: true + cargoHash: + type: string + nullable: true + arrangedAt: + type: string + format: date-time + nullable: true + outbounds: + type: array + items: + $ref: '#/components/schemas/DispatchOutboundReference' + nullable: true + deliveries: + type: array + items: + $ref: '#/components/schemas/DispatchDeliveryReference' + nullable: true + required: + - dispatchNo + - recordVersion + - arrangementKey + - cargoHash + - arrangedAt + - outbounds + - deliveries + DispatchExternalQuote: + type: object + x-java-record: DispatchDeliveryModels.ExternalQuote + properties: + customerId: + type: string + nullable: true + groupKey: + type: string + nullable: true + bookingNo: + type: string + nullable: true + inboundCode: + type: string + nullable: true + currency: + type: string + nullable: true + amount: + type: number + nullable: true + sourceHash: + type: string + nullable: true + DispatchFeeCandidate: + type: object + x-java-record: DispatchDeliveryModels.FeeCandidate + properties: + financeFeeId: + type: string + pattern: ^[0-9]+$ + nullable: true + customerId: + type: string + nullable: true + currency: + type: string + nullable: true + amount: + type: number + nullable: true + sourceVersion: + type: integer + sourceHash: + type: string + nullable: true + status: + type: string + nullable: true + services: + type: array + items: + type: string + nullable: true + sourceKeys: + type: array + items: + type: string + nullable: true + DispatchPreviewGroup: + type: object + x-java-record: DispatchDeliveryModels.PreviewGroup + properties: + groupKey: + type: string + nullable: true + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + destination: + allOf: + - $ref: '#/components/schemas/DispatchDestination' + nullable: true + groupCargoHash: + type: string + nullable: true + cargo: + type: array + items: + $ref: '#/components/schemas/DispatchCargoLine' + nullable: true + externalFreightHash: + type: string + nullable: true + externalQuotes: + type: array + items: + $ref: '#/components/schemas/DispatchExternalQuote' + nullable: true + feeCoverageCargoHash: + type: string + nullable: true + feeSources: + type: array + items: + $ref: '#/components/schemas/DispatchFeeCandidate' + nullable: true + requiredServices: + type: array + items: + type: string + nullable: true + blockReasons: + type: array + items: + type: string + nullable: true + DispatchArrangementPreview: + type: object + x-java-record: DispatchDeliveryModels.ArrangementPreview + properties: + dispatchNo: + type: string + nullable: true + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 + status: + allOf: + - $ref: '#/components/schemas/DispatchStatus' + nullable: true + stage: + allOf: + - $ref: '#/components/schemas/DispatchStage' + nullable: true + cargoHash: + type: string + nullable: true + loadingCompletionHash: + type: string + nullable: true + arrange: + allOf: + - $ref: '#/components/schemas/DispatchGate' + nullable: true + groups: + type: array + items: + $ref: '#/components/schemas/DispatchPreviewGroup' + nullable: true + arrangement: + allOf: + - $ref: '#/components/schemas/DispatchArrangementResult' + nullable: true + required: + - recordVersion + DispatchSaved: + type: object + x-java-record: DispatchResults.Saved + properties: + dispatchNo: + type: string + nullable: true + changedItemIds: + type: array + items: + type: string + pattern: ^[0-9]+$ + nullable: true + required: + - dispatchNo + - changedItemIds + DispatchPicked: + type: object + x-java-record: DispatchResults.Picked + properties: + dispatchItemId: + type: string + pattern: ^[0-9]+$ + nullable: true + positionId: + type: string + pattern: ^[0-9]+$ + nullable: true + returnCartonId: + type: string + pattern: ^[0-9]+$ + nullable: true + packages: + type: string + pattern: ^[0-9]+$ + pickedPackages: + type: string + pattern: ^[0-9]+$ + pickingReconciled: + type: boolean + required: + - dispatchItemId + - positionId + - returnCartonId + - packages + - pickedPackages + - pickingReconciled + DispatchLoaded: + type: object + x-java-record: DispatchResults.Loaded + properties: + loadingCompletionHash: + type: string + nullable: true + loadingCompletedAt: + type: string + format: date-time + nullable: true + confirmedLoad: + allOf: + - $ref: '#/components/schemas/DispatchConfirmedLoad' + nullable: true + required: + - loadingCompletionHash + - loadingCompletedAt + - confirmedLoad + DispatchDeparted: + type: object + x-java-record: DispatchResults.Departed + properties: + departedAt: + type: string + format: date-time + nullable: true + shippedPackages: + type: string + pattern: ^[0-9]+$ + outbounds: + type: array + items: + $ref: '#/components/schemas/DispatchOutboundReference' + nullable: true + deliveries: + type: array + items: + $ref: '#/components/schemas/DispatchDeliveryReference' + nullable: true + required: + - departedAt + - shippedPackages + - outbounds + - deliveries + DispatchPayload: + oneOf: + - $ref: '#/components/schemas/DispatchSaved' + - $ref: '#/components/schemas/DispatchPicked' + - $ref: '#/components/schemas/DispatchLoaded' + - $ref: '#/components/schemas/DispatchDeparted' + - $ref: '#/components/schemas/DispatchArrangementResult' + DispatchEvidenceFile: + type: object + properties: + fileId: + type: string + pattern: ^[1-9][0-9]*$ + fileName: + type: string + contentType: + type: string + enum: + - application/pdf + - image/jpeg + - image/png + sizeBytes: + type: string + pattern: ^[0-9]+$ + DispatchSelectionGroup: + type: object + x-java-record: DispatchPlanningModels.SelectionGroup + properties: + groupKey: + type: string + groupVersion: + type: string + sources: + type: array + items: + $ref: '#/components/schemas/DispatchSelectionGroupSource' + pallets: + type: array + items: + $ref: '#/components/schemas/DispatchSelectionGroupPallet' + complete: + type: boolean + blockReasons: + type: array + items: + type: string + required: + - groupKey + - groupVersion + - sources + - pallets + - complete + - blockReasons + description: 服务端按完整实托及整换标申请计算未被页面筛选截断的来源集合;版本绑定组成、完工轮次、实际位置和各来源版本。complete=false或blockReasons非空禁止选入。 + DispatchSelectionGroupSource: + type: object + x-java-record: DispatchPlanningModels.SelectionGroupSource + properties: + selection: + allOf: + - $ref: '#/components/schemas/DispatchCargoSelection' + sourceKey: + type: string + customerId: + type: string + customerName: + type: string + nullable: true + sourceBusinessType: + type: string + nullable: true + sourceBusinessNo: + type: string + nullable: true + packages: + type: string + pattern: ^[0-9]+$ + required: + - selection + - sourceKey + - customerId + - customerName + - sourceBusinessType + - sourceBusinessNo + - packages + description: 客户和换标业务号按逻辑来源保留;selection携带本来源权威版本,正式建单和加货显式提交完整组,不从展示sourceKey推业务号。 + DispatchSelectionGroupPallet: + type: object + x-java-record: DispatchPlanningModels.SelectionGroupPallet + properties: + warehousePalletId: + type: string + pattern: ^[0-9]+$ + palletNo: + type: string + sourceKeys: + type: array + items: + type: string + packageCount: + type: string + pattern: ^[0-9]+$ + required: + - warehousePalletId + - palletNo + - sourceKeys + - packageCount + description: 每实托只列一次,sourceKeys列出全部逻辑贡献;实际装载按实托测量,不能把整托容量重复计到各来源。 + DispatchSourceOrigin: + type: object + x-java-record: DispatchContracts.SourceOrigin + properties: + dispatchItemSourceId: + type: string + pattern: ^[0-9]+$ + nullable: true + relabelApplicationId: + type: string + pattern: ^[0-9]+$ + nullable: true + relabelItemId: + type: string + pattern: ^[0-9]+$ + nullable: true + sourceInventoryBalanceId: + type: string + pattern: ^[0-9]+$ + nullable: true + sourceInboundOrderId: + type: string + pattern: ^[0-9]+$ + nullable: true + sourceInboundOrderNo: + type: string + nullable: true + sourceInboundCode: + type: string + nullable: true + productQuantity: + type: string + pattern: ^[0-9]+$ + nullable: true + required: + - dispatchItemSourceId + - relabelApplicationId + - relabelItemId + - sourceInventoryBalanceId + - sourceInboundOrderId + - sourceInboundOrderNo + - sourceInboundCode + - productQuantity securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 06561c1e4..5e5ce1334 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -8,39 +8,292 @@ security: paths: /api/pda/v1/last-leg/dispatches: get: - summary: 查询Token绑定仓的排车PDA任务 - description: 仅 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate;跨客户读取绑定仓任务,不接收customerId或warehouseId。 - parameters: [{ name: status, in: query, required: false, schema: { type: string } }] - responses: { '200': { description: 排车任务列表 }, '403': { description: 角色、权限或仓库绑定缺失 } } + summary: 读取Token绑定仓五阶段整车任务 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - type: array + items: + $ref: overseas-business-v1.yaml#/components/schemas/DispatchPdaTask + nullable: true + parameters: + - name: stage + in: query + schema: + $ref: overseas-business-v1.yaml#/components/schemas/DispatchStage /api/pda/v1/last-leg/dispatches/{dispatchNo}: get: - summary: 查询绑定仓排车任务详情 - description: 返回 tripNo、tripRecordVersion、tripDispatchCount、driverWarning、canDepart、departBlockReason、canPick 与 pickBlockReason;门禁反映整车所有客户单据、实际装载及当前司机资格,PDA 不得仅按当前子单拣货数量推断。canPick/pickBlockReason仅反映当前客户排车单调度审核,不以其他客户审核结果替代。未关联车次不可拣货或发车;列表按车次复用同次查询的门禁结果。证照30天内到期但覆盖任务时driverWarning提醒且仍允许,预计结束前过期则阻断。 - parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 排车托盘、目的仓与库存位置详情 }, '404': { description: 当前绑定仓无此排车任务 } } + summary: 读取本仓整车任务及真实货物 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: overseas-business-v1.yaml#/components/schemas/DispatchPdaTask + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading: + get: + summary: 读取本车拣货核平和实际测量依据 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: overseas-business-v1.yaml#/components/schemas/DispatchLoadingPreview + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + /api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}: + get: + summary: 核对本人本仓原命令结果 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult + nullable: true + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + - name: idempotencyKey + in: path + required: true + schema: + type: string /api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick: post: - summary: 扫码拣取排车托盘包裹 - description: 新请求须当前排车调度审核通过;同键同内容重放已保存结果不重新扣库存。 - parameters: - - { name: dispatchNo, in: path, required: true, schema: { type: string } } - - { name: itemId, in: path, required: true, schema: { type: string, pattern: '^[0-9]+$' } } - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchPick' } } } } - responses: { '200': { description: 本次及累计拣货结果;同键同请求重放既有结果 }, '409': { description: 跨仓、跨客户、库存、数量或幂等冲突 } } - /api/pda/v1/last-leg/dispatches/{dispatchNo}/complete: + summary: 按本车真实货物来源执行扫码拣货 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: overseas-business-v1.yaml#/components/schemas/DispatchPick + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string + - name: itemId + in: path + required: true + schema: + type: string + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: - summary: 完成排车拣货 - description: 当前排车调度审核通过且每个要求托盘及全部包裹必须精确完成;不允许部分完成。 - parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } - responses: { '200': { description: READY_TO_DEPART结果 }, '409': { description: 未全部拣货或幂等冲突 } } + summary: 真实拣货核平后显式确认装车完成 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: overseas-business-v1.yaml#/components/schemas/DispatchLoadingComplete + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/pda/v1/last-leg/dispatches/{dispatchNo}/depart: post: - summary: 从排车任务确认所属车次整车发车 - description: 按Token绑定仓锁定车次,校验期望车次版本、车辆占用、全部调度审核、实际装载、整车理货审核及全部客户拣货和待出库单,重新核验司机证照、当前海外区域和身份;区域或身份变化与预览门禁一致阻断。一个事务推进所有子单,任一失败全车回滚。扫码阶段已扣库存不重复扣减;同键同内容重放整车结果,不同内容冲突。DEPARTING仅是同事务内部状态,不对外提交。 - parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DepartAction' } } } } - responses: { '200': { description: '统一响应data包含tripNo、status=IN_TRANSIT、departedAt、shippedPackages、dispatches数组(dispatchNo/shippedPackages)和replayed;每张子排车状态均为DEPARTED' }, '409': { description: 车次版本、装载、资格、拣货、出库或幂等门禁未满足 } } + summary: 完整安排派送后确认实际发车 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: overseas-business-v1.yaml#/components/schemas/DispatchDepart + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/pda/v1/last-leg/outbound-tasks: get: summary: 查询Token绑定仓的一件代发出库任务 @@ -962,24 +1215,6 @@ components: type: object required: [idempotencyKey] properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } - DepartAction: - type: object - required: [recordVersion, idempotencyKey] - properties: - recordVersion: { type: integer, format: int64, minimum: 0, description: 当前车次版本,来自tripRecordVersion} - idempotencyKey: { type: string, minLength: 8, maxLength: 128 } - remark: { type: string, maxLength: 1000, nullable: true } - DispatchPick: - type: object - description: 普通库存提交positionId;原生退货换标箱提交cartonMark、positionId为空、packages=1,productQuantity必须与箱快照一致。两类来源互斥。 - required: [packages, productQuantity, idempotencyKey] - properties: - positionId: { type: string, pattern: '^[0-9]+$', nullable: true } - cartonMark: { type: string, maxLength: 255, description: 本排车来源的系统箱唛 } - packages: { type: integer, format: int64, minimum: 1 } - productQuantity: { type: integer, format: int64, minimum: 0 } - idempotencyKey: { type: string, minLength: 1, maxLength: 255 } - remark: { type: string, maxLength: 1000, nullable: true } OutboundPick: type: object required: [outboundPalletItemId, packages, idempotencyKey] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java index ad8e23f83..840e4c869 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java @@ -17,17 +17,17 @@ class AppointmentImpactRelationTest { s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,active INT,appointment_id BIGINT)"); s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR,appointment_status VARCHAR,fba_warehouse_code VARCHAR,appointment_at TIMESTAMP,timezone VARCHAR)"); s.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)"); - s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,dispatch_no VARCHAR,status VARCHAR)"); - s.execute("CREATE TABLE ll_fleet_trip_dispatch(customer_id VARCHAR,dispatch_no VARCHAR,trip_id BIGINT)"); + s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR,status VARCHAR,stage VARCHAR)"); + s.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT UNIQUE,dispatch_no VARCHAR,trip_id BIGINT UNIQUE)"); s.execute("CREATE TABLE ll_fleet_trip(id BIGINT,trip_no VARCHAR,status VARCHAR,planned_departure_at TIMESTAMP)"); s.execute("CREATE TABLE ll_fleet_external_booking(trip_no VARCHAR,booking_no VARCHAR,customer_name VARCHAR,destination_label VARCHAR,status VARCHAR)"); s.execute("INSERT INTO ll_order VALUES(1,'A','10','FIRST_LEG','PENDING','IN-A','Alpha',10,'ONT8'),(2,'B','20','FIRST_LEG','PENDING','IN-B','Beta',20,'ONT8')"); s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1','SUCCESS','ONT8','2026-10-15','America/Los_Angeles'),(2,'AP-WRONG','SUCCESS','ONT8','2026-10-15','America/Los_Angeles')"); s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',1,1),(1,'B',1,2)"); - s.execute("INSERT INTO ll_dispatch_order VALUES(1,'A','DS-1','PENDING'),(2,'B','DS-WRONG','PENDING')"); - s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2)"); + s.execute("INSERT INTO ll_dispatch_order VALUES(1,'DS-1','ACTIVE','BEFORE_LOADING'),(2,'DS-WRONG','ACTIVE','BEFORE_LOADING')"); + s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2),(2,'B',1)"); s.execute("INSERT INTO ll_fleet_trip VALUES(1,'TR-1','PLANNED','2026-10-15'),(2,'TR-WRONG','PLANNED','2026-10-15')"); - s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES('A','DS-1',1),('B','DS-1',2)"); + s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES(1,'DS-1',1),(2,'DS-WRONG',2)"); s.execute("INSERT INTO ll_fleet_external_booking VALUES('TR-1','FB-1','Other customer','LAX9','CONFIRMED'),('TR-1','FB-CANCEL','Other','ONT8','CANCELLED'),('TR-WRONG','FB-WRONG','Other','ONT8','CONFIRMED')"); } var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); @@ -40,6 +40,9 @@ class AppointmentImpactRelationTest { assertThat(links.trips()).extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1"); assertThat(links.sameTripBookings()).extracting(AppointmentImpactTransportGateway.Booking::bookingNo).containsExactly("FB-1"); assertThat(relations.cargo("20")).extracting(AppointmentImpactRelationMapper.Cargo::inboundCode).containsExactly("IN-B"); + assertThat(relations.dispatches("20")).extracting(AppointmentImpactTransportGateway.Dispatch::dispatchNo).containsExactly("DS-1"); + assertThat(new FleetAppointmentImpactAdapter(session.getMapper(FleetAppointmentImpactMapper.class)).related(relations.dispatches("20")).trips()) + .extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1"); } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index fd9129a73..24a14d742 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -100,7 +100,7 @@ class Phase4BrowserApiBoundaryTest { .filter(method -> method.isAnnotationPresent(PostMapping.class)) .flatMap(method -> Arrays.stream(method.getAnnotation(PostMapping.class).value())) .collect(Collectors.toSet()); - assertEquals(Set.of("/drafts/snapshot", "/pre-arrange", "/drafts/pre-arrange", "/drafts/containers", + assertEquals(Set.of("/drafts/snapshot", "/drafts", "/drafts/pre-arrange", "/drafts/containers", "/draft-locks/lock", "/draft-locks/unlock", "/draft-locks/release"), postPaths); assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) .filter(method -> method.isAnnotationPresent(DeleteMapping.class)).count() == 1); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryCargoGroupsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryCargoGroupsTest.java new file mode 100644 index 000000000..2fc32939b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryCargoGroupsTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.List; +import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*; +import static org.junit.jupiter.api.Assertions.*; + +class DeliveryCargoGroupsTest { + private final DeliveryCargoGroups policy=new DeliveryCargoGroups(new ObjectMapper().findAndRegisterModules()); + /** 同目的仓不同客户分开,同托多来源保留且物理托盘仅计一次。 */ + @Test void groupsByCustomerAndDestinationWithoutLosingMixedPalletSources() { + var a=cargo(1,"101","ONT8",51,2);var b=cargo(2,"101","ONT8",51,3); + var c=cargo(3,"101","LAX9",52,4);var d=cargo(4,"102","ONT8",53,5); + var groups=policy.groups(context(List.of(d,c,b,a),false)); + assertEquals(3,groups.size()); + var mixed=groups.stream().filter(g->g.customerId().equals("101")&&g.destination().warehouseCode().equals("ONT8")).findFirst().orElseThrow(); + assertEquals(5,mixed.packages());assertEquals(1,mixed.palletCount());assertEquals(2,mixed.cargo().size()); + assertEquals(mixed.cargoHash(),policy.groups(context(List.of(a,b,c,d),false)).stream().filter(g->g.key().equals(mixed.key())).findFirst().orElseThrow().cargoHash()); + assertNotEquals(policy.groupKey("101",a.destination()),policy.groupKey("102",a.destination())); + } + /** 空车和重复归属不能靠总箱数相等冒充完整真实来源。 */ + @Test void emptyOrDuplicatedAttributionCannotBecomeArrangement() { + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(),false))); + var row=cargo(1,"101","ONT8",51,2); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(row,row),false))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(row,cargo(2,"101","LAX9",51,3)),false))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java new file mode 100644 index 000000000..df67ddf59 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import java.time.Instant; +import java.util.List; + +/** 本任务测试独占fixture,所有业务号及客户均为离线合成值。 */ +public final class DeliveryDispatchFixtures { + public static final Instant DEPARTED=Instant.parse("2026-10-19T01:00:00Z"); + /** 工具类不允许实例化。 */ + private DeliveryDispatchFixtures() { } + /** 构造一条真实拣货归属,允许同实物托盘有多个来源。 */ + public static CargoLine cargo(long id,String customer,String destination,long pallet,long packages) { + return new CargoLine(id,customer,"客户"+customer,id,"LM-"+id,"SOURCE-"+id,"RECEIVED","LOCAL",null,"IN-"+id, + null,pallet,"PALLET-"+pallet,id,id,id,id,null,null,null,null, + new Destination("FBA:"+destination,"FBA",null,destination,destination,null,null,null,null), + packages,0,null,null,List.of(),null); + } + /** 换标沿用真实履约订单及逐次拣货归属,原货源单独进入冻结快照。 */ + public static CargoLine relabelCargo(long id,String customer,String destination,long pallet,long packages,SourceOrigin origin) { + var row=cargo(id,customer,destination,pallet,packages); + return new CargoLine(row.dispatchItemId(),row.customerId(),row.customerName(),row.orderId(),row.orderNo(), + row.sourceKey(),"RELABEL",row.sourceRegion(),row.sourceInboundId(),row.inboundCode(),row.planPalletId(), + row.warehousePalletId(),row.palletNo(),row.pickId(),row.attributionId(),row.inventoryPositionId(),null,null,null, + row.cartonMark(),"RL-"+origin.relabelApplicationId(),row.destination(),row.packages(),row.productQuantity(), + row.grossWeightKg(),row.volumeCbm(),row.batchAllocations(),origin); + } + /** 构造同仓完整拣货的整车上下文,发车后只改变业务计数。 */ + public static Context context(List cargo,boolean departed) { + long count=cargo.stream().mapToLong(CargoLine::packages).sum(); + return new Context(10L,"DP-10",20L,"TRIP-20","1001","WH-1","测试仓",Status.ACTIVE, + departed?Stage.DELIVERING:Stage.AFTER_LOADING,3,1,"cargo-hash","loading-hash",DEPARTED.minusSeconds(60), + departed?DEPARTED:null,null,new Counts(cargo.size(),cargo.stream().map(CargoLine::warehousePalletId).distinct().count(),count, + departed?0:count,departed?count:0),null,true,cargo); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFactsChangedPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFactsChangedPersistenceTest.java new file mode 100644 index 000000000..46b4c0b8d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFactsChangedPersistenceTest.java @@ -0,0 +1,100 @@ +package com.baiyue.lastleg.delivery; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.mapper.DeliveryFactsChangedMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.repository.DeliveryFactsChangedRepository; +import com.baiyue.lastleg.delivery.service.DeliveryFactsChangedService; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +/** 执行真实生产Mapper与事务,验证信号不能早确认或在回滚后遗留。 */ +class DeliveryFactsChangedPersistenceTest { + private JdbcTemplate db; + private TransactionTemplate tx; + private DeliveryFactsChangedService service; + private final Instant now=Instant.parse("2026-10-19T01:00:00Z"); + + /** 每例建立隔离数据库,SQL写入仅存在于测试fixture。 */ + @BeforeEach void prepare() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:signals-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + db=new JdbcTemplate(ds); + db.execute(""" + CREATE TABLE ll_delivery_facts_changed(id BIGINT PRIMARY KEY,customer_id VARCHAR(30),dispatch_id BIGINT, + dispatch_no VARCHAR(50),delivery_id BIGINT,delivery_no VARCHAR(50),delivery_version BIGINT, + facts_hash VARCHAR(64),occurred_at TIMESTAMP,state VARCHAR(20),attempt_count INT,next_attempt_at TIMESTAMP, + last_error_code VARCHAR(64),processed_at TIMESTAMP,UNIQUE(customer_id,delivery_id,delivery_version)) + """); + db.execute(""" + CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(30),dispatch_order_id BIGINT, + dispatch_no VARCHAR(50),delivery_no VARCHAR(50),version BIGINT,signed_at TIMESTAMP,signed_packages INT) + """); + db.update("INSERT INTO ll_delivery_order VALUES(1,'101',10,'DP-A','DL-A',1,?,5)",local(now)); + var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + config.addMapper(DeliveryFactsChangedMapper.class); config.addMapper(DeliveryOrderMapper.class); + var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); + var session=new SqlSessionTemplate(factory.getObject()); + var repository=new DeliveryFactsChangedRepository(session.getMapper(DeliveryFactsChangedMapper.class),session.getMapper(DeliveryOrderMapper.class)); + var manager=new DataSourceTransactionManager(ds); tx=new TransactionTemplate(manager); + tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED); + var proxy=new ProxyFactory(new DeliveryFactsChangedService(repository,new ObjectMapper().findAndRegisterModules())); + proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource())); + service=(DeliveryFactsChangedService)proxy.getProxy(); + } + + /** 原签收事务失败则不存在候选,原请求重放只有一条事实。 */ + @Test void enqueueRollsBackAndDeduplicatesWithRealMapper() { + assertThrows(IllegalStateException.class,()->tx.execute(s->{service.enqueue("DP-A","DL-A",1,now);throw new IllegalStateException("rollback");})); + assertTrue(service.due(now,20).isEmpty()); + tx.executeWithoutResult(s->{service.enqueue("DP-A","DL-A",1,now);service.enqueue("DP-A","DL-A",1,now);}); + assertEquals(1,service.due(now,20).size()); + assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",2,now))); + db.update("UPDATE ll_delivery_order SET signed_packages=4 WHERE id=1"); + assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now))); + } + + /** 阶段写入与信号ack任一失败都回滚,失败记录保留可重试状态。 */ + @Test void acknowledgmentRollsBackAndRetryNeverReopensProcessedFact() { + tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now)); + Signal signal=service.due(now,20).get(0); + assertThrows(IllegalStateException.class,()->tx.execute(s->{service.acknowledge(signal,now);throw new IllegalStateException("CAS failed");})); + assertEquals(1,service.due(now,20).size()); + service.failed(signal,now.plusSeconds(30),"DISPATCH_RECONCILE_FAILED"); + assertTrue(service.due(now,20).isEmpty()); + assertEquals(1,service.due(now.plusSeconds(30),20).get(0).attempts()); + tx.executeWithoutResult(s->service.acknowledge(signal,now.plusSeconds(30))); + service.failed(signal,now.plusSeconds(60),"DISPATCH_RECONCILE_FAILED"); + assertTrue(service.due(now.plusSeconds(60),20).isEmpty()); + assertEquals("PROCESSED",db.queryForObject("SELECT state FROM ll_delivery_facts_changed",String.class)); + } + + /** 内部ID相同而业务身份不同也不能确认或记录别人的信号。 */ + @Test void mismatchedIdentityAndMissingTransactionAreRejected() { + tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now)); + var original=service.due(now,20).get(0); + var other=new Signal(original.signalId(),"DP-B","DL-A",1,0,now); + assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.acknowledge(other,now))); + assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->service.acknowledge(original,now)); + assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->service.enqueue("DP-A","DL-A",1,now)); + assertEquals(1,service.due(now,20).size()); + } + + /** 与生产现有LocalDateTime字段保持服务端时区语义。 */ + private static LocalDateTime local(Instant value) { return LocalDateTime.ofInstant(value,ZoneId.systemDefault()); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java deleted file mode 100644 index a50c2a54a..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.baiyue.lastleg.delivery; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.client.FinanceFeeCoverageClient; -import com.baiyue.lastleg.delivery.controller.PlatformDeliveryFeeCoverageController; -import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; -import com.baiyue.lastleg.delivery.dto.DeliveryRequests; -import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper; -import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService; -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.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; -import java.util.List; -import java.util.Set; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -class DeliveryFeeCoverageServiceTest { - /** 同一排车明细中的外接货覆盖不能免掉普通来源的应收。 */ - @Test void externalFreightCoversOnlyItsOwnInboundEvenWhenAnItemIsShared() { - when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 11)))); - var preview = service.preview("DP-1"); - var selection = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101, 11).sourceHash(), List.of("LOADING_FEE")); - var result = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selection)), "OP", Set.of("IN-A")); - assertThat(result.includedServices()).doesNotContain("DELIVERY_FEE"); - assertThat(result.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::inboundCode).containsExactly("IN-B"); - } - final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class); - final DispatchItemMapper items = mock(DispatchItemMapper.class); - final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class); - final FinanceFeeCoverageClient finance = mock(FinanceFeeCoverageClient.class); - final DeliveryFeeCoverageService service = new DeliveryFeeCoverageService(dispatches, items, cargo, finance, - new ObjectMapper().findAndRegisterModules(), "coverage-test-token"); - @BeforeEach void setup() { - var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); dispatch.setDispatchNo("DP-1"); - when(dispatches.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch); - var selected = new DispatchItem(); selected.setId(201L); selected.setPackageCount(20); - when(items.findByDispatchOrder("101", 1L)).thenReturn(List.of(selected)); - when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10), source("12", "IN-B", 10))); - } - @Test void batchesBothActualInboundSourcesInsteadOfUsingOneDisplayCode() { - var query = new FinanceFeeCoverageClient.Query(101L, List.of(11L, 12L)); - when(finance.sources("coverage-test-token", query)).thenReturn(ApiResponse.success(List.of(fee(101, 11)))); - var result = service.preview("DP-1"); - assertThat(result.sourceComplete()).isTrue(); assertThat(result.cargo()).hasSize(2); assertThat(result.feeSources()).hasSize(1); - assertThat(result.cargoHash()).hasSize(64); - verify(finance).sources("coverage-test-token", query); verifyNoMoreInteractions(finance); - } - @Test void incompletePhysicalSourcesNeverProduceAFalseCoverageOption() { - when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10))); - var result = service.preview("DP-1"); - assertThat(result.sourceComplete()).isFalse(); assertThat(result.blockReason()).contains("箱数不一致"); - assertThat(result.feeSources()).isEmpty(); verifyNoInteractions(finance); - } - @Test void unavailableOrWrongScopeFinanceResultsFailClosed() { - when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(202, 11)))); - assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("503"); - when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 999)))); - assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class); - when(finance.sources(anyString(), any())).thenReturn(null); - assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class); - } - @Test void platformReadonlyEndpointDoesNotAcceptMerchantIdentityOrAClientSuppliedCustomer() throws Exception { - var controller = new PlatformDeliveryFeeCoverageController(service); - var merchant = new AuthenticatedUser(7L, 101L, 101L, 8L, "merchant", "MERCHANT", "WEB", Set.of()); - assertThatThrownBy(() -> controller.preview(merchant, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("403"); - assertThatThrownBy(() -> controller.preview(null, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("401"); - var method = PlatformDeliveryFeeCoverageController.class.getMethod("preview", AuthenticatedUser.class, String.class); - assertThat(method.getAnnotation(PreAuthorize.class).value()).contains("platform:overseas-warehouse:dispatch:list"); - verifyNoInteractions(dispatches, items, cargo, finance); - } - @Test void onlyFullyCoveredServicesCanRemoveANewChargeAndTheSnapshotKeepsEveryActualSource() { - when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11), fee(101,12)))); - var preview = service.preview("DP-1"); - var a = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE")); - var partial = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a)), "OP-1", Set.of()); - assertThat(partial.includedServices()).isEmpty(); - assertThat(partial.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::sourceInboundId).containsExactly("12"); - var b = new DeliveryFeeCoverageModels.Selection(712L, 1, fee(101,12).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE")); - var complete = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a,b)), "OP-1", Set.of()); - assertThat(complete.includedServices()).containsExactlyInAnyOrder("DELIVERY_FEE", "LOADING_FEE"); - assertThat(complete.evidence().cargo()).hasSize(2); assertThat(complete.evidence().references()).hasSize(2); - assertThat(complete.evidence().operatorId()).isEqualTo("OP-1"); - } - @Test void staleCargoFeeVersionAndRepeatedCoverageAreRejected() { - when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11)))); - var preview = service.preview("DP-1"); - var selected = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE")); - assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest("old-cargo", List.of(selected)), "OP-1", Set.of())).hasMessageContaining("货物已变化"); - var old = new DeliveryFeeCoverageModels.Selection(711L, 2, selected.sourceHash(), selected.services()); - assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(old)), "OP-1", Set.of())).hasMessageContaining("版本已变化"); - assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selected, selected)), "OP-1", Set.of())).hasMessageContaining("只能引用一项"); - } - static DeliveryRequests.Transfer coverageRequest(String hash, List selections) { - return new ObjectMapper().findAndRegisterModules().convertValue(java.util.Map.of("feeCoverageCargoHash", hash, "feeCoverage", selections), DeliveryRequests.Transfer.class); - } - static DeliveryFeeCoverageModels.Cargo source(String id, String code, long packages) { - return new DeliveryFeeCoverageModels.Cargo(201L, 501L, "PLT-501", Long.valueOf(id), "FIRSTLEG", id, code, packages, "IN-A"); - } - static FinanceFeeCoverageClient.Fee fee(long customer, long inbound) { - return new FinanceFeeCoverageClient.Fee(700L + inbound, 800L + inbound, "INBOUND_FEE", 1, customer, inbound, - "IN-A", "FREIGHT", "原运费", "CNY", BigDecimal.TEN, "sha256:" + "a".repeat(64), "AVAILABLE"); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java index 8001a026f..9d2269b74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java @@ -11,13 +11,14 @@ import static org.assertj.core.api.Assertions.*; /** Executes the production annotation SQL against real H2 tables, not mocked mapper results. */ class DeliverySourceEvidenceMapperTest { + /** 同一任务承载两客户,历史只读取本客户全部真实来源且不依赖当前代表派送号。 */ @Test void historySurvivesCurrentDeliveryRebindingAndKeepsAllCoShippedSources() throws Exception { var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds); db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,delivery_order_no VARCHAR(30))"); - db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,departed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); - db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)"); db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); @@ -25,12 +26,17 @@ class DeliverySourceEvidenceMapperTest { db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))"); db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S-A',4,'NEW-DELIVERY'),(102,'C','FIRSTLEG','S-B',6,'OTHER-DELIVERY')"); - db.update("INSERT INTO ll_dispatch_order VALUES(10,'C','2026-10-03 01:00:00')"); + db.update("INSERT INTO ll_dispatch_order VALUES(10,'2026-10-03 01:00:00')"); db.update("INSERT INTO ll_delivery_order VALUES(20,'C',10,'CODE:A',10,8,'2026-10-03 03:00:00','OUT-1')"); - db.update("INSERT INTO ll_dispatch_pda_task VALUES(1,'C',10)"); + db.update("INSERT INTO ll_dispatch_pda_task VALUES(1,10)"); db.update("INSERT INTO ll_dispatch_item VALUES(7,'C',10,'FBA',NULL,NULL,'a',NULL,NULL)"); db.update("INSERT INTO ll_dispatch_pda_pick VALUES(2,'C',1,7)"); db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(3,'C',2,101,4,30,NULL),(4,'C',2,102,6,31,NULL)"); + db.update("INSERT INTO ll_order VALUES(103,'OTHER','FIRSTLEG','S-C',9,NULL)"); + db.update("INSERT INTO ll_delivery_order VALUES(21,'OTHER',10,'CODE:B',9,0,NULL,'OUT-2')"); + db.update("INSERT INTO ll_dispatch_item VALUES(8,'OTHER',10,'FBA',NULL,NULL,'b',NULL,NULL)"); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(5,'OTHER',1,8)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(6,'OTHER',5,103,9,32,NULL)"); db.update("INSERT INTO ll_exception_order VALUES(30,'C',20,'EX-1',101)"); db.update("INSERT INTO ll_inventory_ledger VALUES(40,'C','EX-1','OUT-1','RETURN_STOCK_IN',2,'2026-10-03 02:00:00','RETURN_SOURCE:7:8'),(41,'C','EX-1','OUT-1','RETURN_STOCK_IN',1,'2026-10-03 04:00:00','RETURN_SOURCE:7:8')"); var args = Map.of("customerId","C","sourceInboundId","S-A","orderId",101L,"dispatchId",10L); @@ -40,6 +46,12 @@ class DeliverySourceEvidenceMapperTest { var picks = named.queryForList(sql("picks"),args); assertThat(picks).hasSize(2); assertThat(picks.stream().mapToLong(r -> ((Number)r.get("packages")).longValue()).sum()).isEqualTo(10); assertThat(picks).allSatisfy(r -> assertThat(r.get("group_key")).isEqualTo("CODE:A")); + var otherOwner=Map.of("customerId","OTHER","sourceInboundId","S-C","orderId",103L,"dispatchId",10L); + assertThat(named.queryForList(sql("picks"),otherOwner)).singleElement().satisfies(r -> { + assertThat(((Number)r.get("packages")).longValue()).isEqualTo(9); + assertThat(r.get("group_key")).isEqualTo("CODE:B"); + }); + assertThat(named.queryForList(sql("deliveries"),otherOwner)).hasSize(1); assertThat(named.queryForList(sql("returns"),args)).hasSize(2); db.update("INSERT INTO ll_return_stock_receipt VALUES(90,'C',101,40,2,30,'OUT-1',7,8)"); db.update("UPDATE ll_dispatch_pda_pick_order SET receipt_id=NULL,return_receipt_id=90,packages=2 WHERE id=3"); @@ -49,10 +61,11 @@ class DeliverySourceEvidenceMapperTest { db.update("UPDATE ll_return_stock_receipt SET order_id=102 WHERE id=90"); assertThat(named.queryForList(sql("picks"),args).get(0).get("return_ledger_id")).isNull(); for (var method : List.of("source","dispatchIds","deliveries","picks","returns")) { - var other = new HashMap<>(args); other.put("customerId","OTHER"); + var other = new HashMap<>(args); other.put("customerId","UNRELATED"); assertThat(named.queryForList(sql(method),other)).isEmpty(); } } + /** 直接执行生产注解SQL,仅转换同名绑定参数。 */ private String sql(String name) { var method = Arrays.stream(DeliverySourceEvidenceMapper.class.getMethods()).filter(m -> m.getName().equals(name)).findFirst().orElseThrow(); return String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}", ":$1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java index 1331aac3a..ddeb99242 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java @@ -39,12 +39,13 @@ class DeliverySourcePublicationPersistenceTest { DeliverySourceEvidenceMapper mapper; volatile Runnable beforeFirstSourceRead = () -> {}; final Map> sourceLocks=new ConcurrentHashMap<>(); + /** 原生整车和唯一任务没有客户头,发布证据沿真实明细、扫码与来源行读取。 */ Fixture() { org.springframework.test.util.ReflectionTestUtils.setField(service,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); - db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,status VARCHAR(20),stage VARCHAR(30),departed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); - db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)"); db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); @@ -113,7 +114,7 @@ class DeliverySourcePublicationPersistenceTest { when(outbound.findByNo(anyString(),anyString())).thenReturn(shipped); var files=mock(DeliveryDocumentFileGateway.class); when(files.metadata(anyString(),anyLong(),any(),anyLong())).thenReturn(new com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File(1L,"receipt.png","image/png",1,LocalDateTime.of(2026,1,1,0,0))); - Map fields=Map.ofEntries(Map.entry("orders",orders),Map.entry("outboundOrders",outbound),Map.entry("revisions",revisions),Map.entry("operations",operations),Map.entry("files",files),Map.entry("objectMapper",json),Map.entry("lastlegOrders",tails),Map.entry("deliveryCompletedOutbox",publisher),Map.entry("customerVisits",mock(CustomerVisitService.class)),Map.entry("sourceEvidence",mapper),Map.entry("sourceCompletion",new SourceDeliveryCompletionQuery(mapper))); + Map fields=Map.ofEntries(Map.entry("repository",new com.baiyue.lastleg.delivery.repository.DeliveryDocumentRepository(orders,outbound,revisions,operations,tails,mapper)),Map.entry("signals",mock(com.baiyue.lastleg.dispatch.port.DispatchSignalPort.class)),Map.entry("files",files),Map.entry("objectMapper",json),Map.entry("deliveryCompletedOutbox",publisher),Map.entry("customerVisits",mock(CustomerVisitService.class)),Map.entry("sourceCompletion",new SourceDeliveryCompletionQuery(mapper))); fields.forEach((name,value)->ReflectionTestUtils.setField(service,name,value)); var proxyFactory=new org.springframework.aop.framework.ProxyFactory(service);proxyFactory.setProxyTargetClass(true); proxyFactory.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource())); @@ -131,10 +132,11 @@ class DeliverySourcePublicationPersistenceTest { Save request(String key) { return new Save(0L,key,null,List.of(new Binding(DeliveryDocumentCategory.RECEIPT,List.of(1L)))); } void sign(long id,String key) { service.save("C",String.valueOf(id),"TEST",request(key)); } void source(long id,int packages) { db.update("INSERT INTO ll_order(id,customer_id,source_region,source_inbound_id,package_count) VALUES(?,'C','FIRSTLEG',?,?)",id,"S-"+id,packages); } + /** 创建真实客户明细成员及原生departed_at,保留签收与重派的确定时间顺序。 */ void shipment(long id,int packages) { - db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-01-01 01:00:00')",id); + db.update("INSERT INTO ll_dispatch_order(id,status,stage,departed_at) VALUES(?,'ACTIVE','DELIVERING','2026-01-01 01:00:00')",id); db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(?,'C',?,'CODE:A',?,?)",id,id,packages,"OUT-"+id); - db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",id,id); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id) VALUES(?,?)",id,id); db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",id,id); db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",id,id,id); } @@ -156,17 +158,17 @@ class DeliverySourcePublicationPersistenceTest { f.sign(1,"same-key");assertThat(f.published()).containsExactly("S-102"); assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isEqualTo(1); // Re-dispatch only the two previously unsigned boxes. Original current delivery pointers are absent. - f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1)); + f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1)); f.pick(3,2,101,2,30L); // Keep deterministic chronology without sleeping or depending on wall clock granularity. - f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2"); + f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-03 00:00:00' WHERE id=2"); f.sign(2,"redelivery");assertThat(f.published()).containsExactly("S-101","S-102"); } @Test void postSignatureRedeliveryDoesNotFillNeverShippedFourBoxes() { var f=new Fixture();f.source(101,10);f.shipment(1,6);f.pick(1,1,101,6,null);f.sign(1,"first"); f.db.update("UPDATE ll_delivery_order SET signed_at='2026-01-02 00:00:00' WHERE id=1"); f.returned(30,1,101,4,LocalDateTime.of(2026,1,3,0,0)); - f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-04 00:00:00' WHERE id=2"); + f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-04 00:00:00' WHERE id=2"); f.pick(2,2,101,4,30L);f.sign(2,"repeat-four"); assertThat(f.published()).isEmpty();assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY"); } @@ -211,9 +213,9 @@ class DeliverySourcePublicationPersistenceTest { @Test void multiHopPreSignatureRedeliveryPublishesWhenLastUnsignedBoxIsSigned() { var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null); f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"first");assertThat(f.published()).isEmpty(); - f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L); + f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L); f.returned(40,2,101,1,LocalDateTime.of(2026,1,4,0,0));f.sign(2,"second");assertThat(f.published()).isEmpty(); - f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L); + f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L); f.sign(3,"third");assertThat(f.published()).containsExactly("S-101"); } @Test void unrelatedUnattributedDestinationDoesNotBlockSignedSource() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java new file mode 100644 index 000000000..74a79d9f2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java @@ -0,0 +1,180 @@ +package com.baiyue.lastleg.delivery; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.delivery.service.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.entity.*; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; +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.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.lang.reflect.Modifier; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */ +class DispatchDeliveryArrangementPersistenceTest { + private JdbcTemplate db; + private DispatchDeliveryService service; + private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class); + private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(json); + private Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false); + private final Actor actor=new Actor("TEST",ActorMode.PLATFORM,null); + + /** 隔离测试数据库只用于生产Mapper验证,不连接环境或启动服务。 */ + @BeforeEach void prepare() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + db=new JdbcTemplate(ds); + for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) { + String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{ + String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase(); + String sqlType=f.getType()==String.class?"VARCHAR(8000)":f.getType()==LocalDateTime.class?"TIMESTAMP": + f.getType()==java.math.BigDecimal.class?"DECIMAL(24,6)":"BIGINT"; + return name+" "+sqlType+(f.getName().equals("id")?(type==LastlegOrderStatusHistory.class?" AUTO_INCREMENT PRIMARY KEY":" PRIMARY KEY"):""); + }).collect(Collectors.joining(",")); + db.execute("CREATE TABLE "+type.getAnnotation(TableName.class).value()+"("+columns+")"); + } + db.execute("ALTER TABLE ll_outbound_order ADD UNIQUE(dispatch_order_id,customer_id)"); + db.execute("ALTER TABLE ll_delivery_order ADD UNIQUE(customer_id,outbound_order_id,delivery_group_key)"); + db.execute("ALTER TABLE ll_outbound_pallet_item ADD UNIQUE(customer_id,source_attribution_id)"); + db.execute(""" + CREATE TABLE ll_dispatch_delivery_arrangement(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE, + dispatch_no VARCHAR(128),arrangement_key VARCHAR(128) UNIQUE,business_hash VARCHAR(64),cargo_hash VARCHAR(64), + loading_completion_hash VARCHAR(64),original_command_key VARCHAR(255) UNIQUE,original_operator_id VARCHAR(128), + result_json CLOB,cargo_json CLOB,created_at TIMESTAMP) + """); + db.update("INSERT INTO ll_order(id,customer_id,current_status,version) VALUES(1,'101','INBOUND_COMPLETED',0),(2,'102','INBOUND_COMPLETED',0)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + for(var mapper:List.of(DispatchDeliveryArrangementMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class, + DeliveryOrderMapper.class,DeliveryOperationMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class))config.addMapper(mapper); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); + var repository=new DispatchDeliveryRepository(session.getMapper(DispatchDeliveryArrangementMapper.class),session.getMapper(OutboundOrderMapper.class), + session.getMapper(OutboundPalletItemMapper.class),session.getMapper(DeliveryOrderMapper.class),session.getMapper(DeliveryOperationMapper.class), + session.getMapper(LastlegOrderMapper.class),session.getMapper(LastlegOrderStatusHistoryMapper.class)); + var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2)); + var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json); + var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource())); + service=(DispatchDeliveryService)proxy.getProxy(); + when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty()); + when(fees.external(any())).thenReturn(Map.of("101",new FleetBookingFreight.Preview("freight",true,List.of(),List.of()), + "102",new FleetBookingFreight.Preview("freight",true,List.of(),List.of()))); + when(execution.recordArrangement(any(),any(),any(),any(),anyBoolean())).thenThrow(new IllegalStateException("主单CAS失败")); + } + + /** 第二客户写失败会回滚首客户,主单CAS失败也不能留下两张出库或来源绑定。 */ + @Test void mainCasFailureRollsBackAllCustomersAndSources() { + assertThrows(IllegalStateException.class,()->service.arrange(actor,"DP-10",request())); + for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history")) + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class)); + } + + /** 一个客户费用失败不把另一客户已创建的单据单独提交。 */ + @Test void laterCustomerFeeFailureRollsBackEveryDocument() { + doThrow(new IllegalStateException("费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,()->service.arrange(actor,"DP-10",request())); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class)); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class)); + verify(execution,never()).recordArrangement(any(),any(),any(),any(),anyBoolean()); + } + + /** 真实 Mapper 保存安排和逐次归属后,出库及派送均可独立定位原货源,不依赖可变履约单回填。 */ + @Test void persistedAttributionAndDeliveryReferencesResolveImmutableRelabelOrigins() throws Exception { + var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); + var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null); + var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null); + var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L); + context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second), + relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false); + db.update("INSERT INTO ll_order(id,customer_id,current_status,version) VALUES(3,'101','INBOUND_COMPLETED',0),(4,'101','INBOUND_COMPLETED',0)"); + // 成功用例覆盖默认 CAS 失败桩时不能调用旧桩;保留失败用例原有回滚语义。 + doAnswer(a->{ + ArrangementFact fact=a.getArgument(3);CommandIdentity identity=a.getArgument(2); + return new CommandResult("DP-10",Action.ARRANGE_DELIVERY,identity.idempotencyKey(),4,Status.ACTIVE,Stage.AFTER_LOADING, + DEPARTED,Effect.APPLIED,context.counts(),fact.result(),false,false); + }).when(execution).recordArrangement(any(),any(),any(),any(),anyBoolean()); + var result=service.arrange(actor,"DP-10",request()); + assertNotNull(result); + assertEquals(4,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_pallet_item",Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class)); + assertEquals(3,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class)); + assertEquals(5,db.queryForObject("SELECT package_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'",Integer.class)); + assertEquals(1,db.queryForObject("SELECT pallet_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'",Integer.class)); + assertEquals(2,db.queryForObject("SELECT pallet_count FROM ll_outbound_order WHERE customer_id='101'",Integer.class)); + + // 修改履约单展示事实后,仍只通过原安排快照和已保存的真实归属定位来源。 + db.update("UPDATE ll_order SET order_no=CONCAT('CHANGED-',id)"); + var outboundRows=db.queryForList(""" + SELECT i.customer_id,i.source_attribution_id,i.source_order_id,i.dispatch_item_id,i.package_count, + i.gross_weight_kg,i.volume_cbm,CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json + FROM ll_outbound_pallet_item i + JOIN ll_outbound_order o ON o.id=i.outbound_order_id AND o.customer_id=i.customer_id + JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=o.dispatch_order_id + ORDER BY i.source_attribution_id + """); + for (var item:outboundRows) { + List frozen=json.readValue(item.get("CARGO_JSON").toString(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); + long attribution=((Number)item.get("SOURCE_ATTRIBUTION_ID")).longValue(); + var row=frozen.stream().filter(c->c.customerId().equals(item.get("CUSTOMER_ID"))&&c.attributionId()==attribution) + .findFirst().orElseThrow(); + assertEquals(context.cargo().stream().filter(c->c.attributionId()==attribution).findFirst().orElseThrow().sourceOrigin(),row.sourceOrigin()); + assertEquals(row.orderId().longValue(),((Number)item.get("SOURCE_ORDER_ID")).longValue()); + assertNotEquals(row.sourceOrigin().sourceInboundOrderId().longValue(),((Number)item.get("SOURCE_ORDER_ID")).longValue()); + assertEquals(row.packages(),((Number)item.get("PACKAGE_COUNT")).longValue()); + assertNull(item.get("GROSS_WEIGHT_KG"));assertNull(item.get("VOLUME_CBM")); + } + // 费用的 DELIVERY_ORDER.businessId 指向此真实派送行,可按客户及目的地取回其全部冻结来源。 + var deliveryRows=db.queryForList(""" + SELECT d.id,d.customer_id,d.delivery_group_key,d.package_count,d.pallet_count, + CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json + FROM ll_delivery_order d + JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=d.dispatch_order_id + ORDER BY d.customer_id,d.delivery_group_key + """); + int referencedSources=0; + for (var delivery:deliveryRows) { + List frozen=json.readValue(delivery.get("CARGO_JSON").toString(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); + var group=frozen.stream().filter(c->c.customerId().equals(delivery.get("CUSTOMER_ID")) + && grouping.documentGroupKey(c.destination()).equals(delivery.get("DELIVERY_GROUP_KEY"))).toList(); + referencedSources+=group.size(); + assertFalse(group.isEmpty());assertTrue(group.stream().allMatch(c->c.sourceOrigin()!=null)); + assertEquals(((Number)delivery.get("PACKAGE_COUNT")).longValue(),group.stream().mapToLong(CargoLine::packages).sum()); + assertEquals(((Number)delivery.get("PALLET_COUNT")).longValue(),group.stream().map(CargoLine::warehousePalletId).distinct().count()); + if (group.size()==2) assertEquals(List.of(first,second),group.stream().map(CargoLine::sourceOrigin).toList()); + } + assertEquals(4,referencedSources); + List frozen=json.readValue(db.queryForObject("SELECT cargo_json FROM ll_dispatch_delivery_arrangement",String.class), + json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); + assertEquals(3,frozen.stream().filter(c->c.sourceOrigin().productQuantity()==null).count()); + assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity()); + } + + /** 按真实共享记录构造完整客户目的地请求。 */ + private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g-> + new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null, + "freight",g.cargoHash(),List.of(),List.of())).toList()); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java new file mode 100644 index 000000000..6b806fbe6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.client.FinanceFeeCoverageClient; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.delivery.service.DispatchDeliveryFees; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.SourceOrigin; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.math.BigDecimal; +import java.util.List; +import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 新整车收费逻辑直接验证真实客户、明确覆盖、零约定和逐组归属。 */ +class DispatchDeliveryFeesTest { + private final FleetBookingFreight external=mock(FleetBookingFreight.class); + private final FinanceFeeCoverageClient finance=mock(FinanceFeeCoverageClient.class); + private final OverseasFeeSourceService sources=mock(OverseasFeeSourceService.class); + private final DispatchDeliveryRepository repository=mock(DispatchDeliveryRepository.class); + private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(new ObjectMapper().findAndRegisterModules()); + private final DispatchDeliveryFees service=new DispatchDeliveryFees(external,finance,sources,grouping,repository,"offline-test-token"); + + /** A客户原费不能免除B客户;全部覆盖只禁止应收,应付仍独立。 */ + @Test void coverageRequiresCurrentCustomerVersionAndKeepsPayablesIndependent() { + var row=firstleg(cargo(1,"101","ONT8",51,2));var group=grouping.groups(context(List.of(row),false)).get(0); + var fee=original(101);when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(fee))); + var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of()); + var evidence=service.coverage(group,input,"OP",ordinary()); + service.validate(group,input,evidence,ordinary()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,input.feeCoverage(),List.of(fee("RECEIVABLE"))),evidence,ordinary())); + service.validate(group,input(group,input.feeCoverage(),List.of(fee("PAYABLE"))),evidence,ordinary()); + when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(102)))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group,input,"OP",ordinary())); + when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(fee))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group, + input(group,List.of(new CoverageSelection(71L,2,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of()),"OP",ordinary())); + } + + /** 无引用时不强制Finance可用,已有外接约定组禁止新增派送应收。 */ + @Test void ordinaryNeedsPositiveFeeAndExternalQuoteDoesNotWaiveOtherCargo() { + var row=cargo(1,"101","ONT8",51,2);var group=grouping.groups(context(List.of(row),false)).get(0); + var input=input(group,List.of(),List.of()); + assertNull(service.coverage(group,input,"OP",ordinary()));verifyNoInteractions(finance); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,null,ordinary())); + service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary()); + var quote=new FleetBookingFreight.Quote("BOOK-1",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO); + var allExternal=new FleetBookingFreight.Preview("freight",false,List.of(quote),List.of()); + service.validate(group,input,null,allExternal); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,allExternal)); + var mixed=grouping.groups(context(List.of(row,cargo(2,"101","ONT8",51,3)),false)).get(0); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal)); + } + + /** 正数报价发布到自己的客户目的地,零约定仅保存商业操作不造零费用。 */ + @Test void zeroAgreementRecordsFactAndPositiveFeeUsesOwnDelivery() { + var row=cargo(1,"101","ONT8",51,2);var context=context(List.of(row),false);var group=grouping.groups(context).get(0); + var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001"); + var zero=new FleetBookingFreight.Quote("BOOK-ZERO",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO); + service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(zero),List.of()),"OP","ARR-1"); + verify(repository).operation(any());verifyNoInteractions(sources); + var positive=new FleetBookingFreight.Quote("BOOK-POSITIVE",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.TEN,BigDecimal.ONE,"单",BigDecimal.TEN); + service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(positive),List.of()),"OP","ARR-1"); + var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);verify(sources).publishApproved(eq("OP"),anyString(),saved.capture()); + assertEquals("101",saved.getValue().customerId());assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo()); + assertEquals(BigDecimal.TEN,saved.getValue().amount()); + } + + /** 合托两票一笔原费只能覆盖对应来源,不得免掉另一票新增应收。 */ + @Test void mixedOriginalSourcesAreBatchedAndPartialCoverageStillRequiresFee() { + var first=firstleg(cargo(1,"101","ONT8",51,2));var raw=firstleg(cargo(2,"101","ONT8",51,3)); + var second=new CargoLine(raw.dispatchItemId(),raw.customerId(),raw.customerName(),raw.orderId(),raw.orderNo(),raw.sourceKey(),raw.sourceStage(), + raw.sourceRegion(),"502",raw.inboundCode(),raw.planPalletId(),raw.warehousePalletId(),raw.palletNo(),raw.pickId(),raw.attributionId(),raw.inventoryPositionId(), + raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations(),raw.sourceOrigin()); + var group=grouping.groups(context(List.of(first,second),false)).get(0); + when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(101)))); + var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of()); + var evidence=service.coverage(group,input,"OP",ordinary()); + assertEquals(2,evidence.cargo().size()); + verify(finance).sources("offline-test-token",new FinanceFeeCoverageClient.Query(101L,List.of(501L,502L))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,evidence,ordinary())); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group, + input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary())); + } + + /** 多个换标原货源共组费用只归真实派送,不借第一票原货源或履约单伪造单一归属。 */ + @Test void relabelFeeRetainsDeliveryReferenceWithoutInventingOneOrigin() { + var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); + var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null); + var context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first), + relabelCargo(3,"101","ONT8",51,3,second)),false); + var group=grouping.groups(context).get(0); + var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101"); + delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001"); + service.publish(context,group,input(group,List.of(),List.of(fee("RECEIVABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL"); + var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class); + verify(sources).publishApproved(eq("OP"),anyString(),saved.capture()); + assertEquals("DELIVERY_ORDER",saved.getValue().businessType()); + assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo()); + assertEquals("101",saved.getValue().customerId()); + assertNull(saved.getValue().orderId());assertNull(saved.getValue().inboundId());assertNull(saved.getValue().inboundCode()); + assertEquals(List.of(first,second),group.cargo().stream().map(CargoLine::sourceOrigin).toList()); + assertTrue(group.cargo().stream().allMatch(c->c.sourceOrigin().productQuantity()==null&&c.grossWeightKg()==null&&c.volumeCbm()==null)); + assertEquals(5,group.packages());assertEquals(1,group.palletCount()); + } + + /** 只读平台候选接口仍拒绝客户身份,不能借组结果跨客户访问。 */ + @Test void readOnlyCoverageControllerRejectsMerchantBeforeQuery() throws Exception { + var delivery=mock(com.baiyue.lastleg.delivery.service.DispatchDeliveryService.class); + var controller=new com.baiyue.lastleg.delivery.controller.PlatformDeliveryFeeCoverageController(delivery); + var merchant=new com.baiyue.common.security.AuthenticatedUser(1L,101L,101L,1L,"test","MERCHANT","WEB",java.util.Set.of()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->controller.preview(merchant,"DP-10")); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->controller.preview(null,"DP-10")); + var method=controller.getClass().getMethod("preview",com.baiyue.common.security.AuthenticatedUser.class,String.class); + assertTrue(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value().contains("dispatch:list")); + verifyNoInteractions(delivery); + } + + /** 构造当前客户原费用证据。 */ + private FinanceFeeCoverageClient.Fee original(long customer) { return new FinanceFeeCoverageClient.Fee(71L,72L,"INBOUND_FEE",1,customer,501L,"IN-1","FIRSTLEG","原费用","CNY",BigDecimal.TEN,"FEE-HASH","AVAILABLE"); } + /** 真实头程关联只在来源行声明,不从展示编号推断。 */ + private CargoLine firstleg(CargoLine c) { return new CargoLine(c.dispatchItemId(),c.customerId(),c.customerName(),c.orderId(),c.orderNo(),c.sourceKey(),c.sourceStage(), + "FIRSTLEG","501",c.inboundCode(),c.planPalletId(),c.warehousePalletId(),c.palletNo(),c.pickId(),c.attributionId(),c.inventoryPositionId(),c.receiptId(), + c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations(),c.sourceOrigin()); } + /** 输入仅使用共同ArrangeGroup,不保留旧单客户协议别名。 */ + private ArrangeGroup input(DeliveryCargoGroups.Group group,List coverage,List fees) { + return new ArrangeGroup(group.key(),group.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",group.cargoHash(),coverage,fees); + } + /** 无外接约定仍需按普通未覆盖部分收费。 */ + private FleetBookingFreight.Preview ordinary() { return new FleetBookingFreight.Preview("freight",true,List.of(),List.of()); } + /** 同一费用类型的应收/应付保留独立规则。 */ + private FeeItem fee(String direction) { return new FeeItem("DELIVERY_FEE","派送费",direction,"USD",BigDecimal.TEN,null,null,null,null,null,null,null,null,null,null,null,null,null,null); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java new file mode 100644 index 000000000..f87732f13 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java @@ -0,0 +1,157 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.delivery.service.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; +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 java.util.*; +import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 实际整车服务配合领域策略验证分组、数组、锁点及未知结果恢复。 */ +class DispatchDeliveryServiceTest { + private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class); + private final DispatchDeliveryRepository repository=mock(DispatchDeliveryRepository.class); + private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(json); + private final Actor actor=new Actor("OP-A",ActorMode.PLATFORM,null); + private final List outbounds=new ArrayList<>(); + private final List deliveries=new ArrayList<>(); + private final List calls=new ArrayList<>(); + private DispatchDeliveryService service; + private Context context; + private DispatchDeliveryArrangement arrangement; + + /** 保存真实领域结果,持久化回滚另由Mapper事务测试负责。 */ + @BeforeEach void prepare() { + context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"101","LAX9",52,3),cargo(3,"102","ONT8",53,4)),false); + var numbers=mock(LastlegOrderNoGenerator.class); + when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2)); + service=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json); + when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context); + when(execution.replay(any(),any(),any())).thenReturn(Optional.empty()); + when(repository.arrangement(eq(10L),anyBoolean())).thenAnswer(a->arrangement); + when(repository.outbounds(eq(10L),anyBoolean())).thenAnswer(a->List.copyOf(outbounds)); + when(repository.deliveries(eq(10L),anyBoolean())).thenAnswer(a->List.copyOf(deliveries)); + doAnswer(a->{outbounds.add(a.getArgument(0));calls.add("OUTBOUND");return null;}).when(repository).create(any(OutboundOrder.class)); + doAnswer(a->{deliveries.add(a.getArgument(0));calls.add("DELIVERY");return null;}).when(repository).create(any(DeliveryOrder.class)); + doAnswer(a->{arrangement=a.getArgument(0);return null;}).when(repository).create(any(DispatchDeliveryArrangement.class)); + when(repository.lockSource(anyString(),anyLong())).thenAnswer(a->{calls.add("SOURCE");var o=new LastlegOrder();o.setId(a.getArgument(1)); + o.setCustomerId(a.getArgument(0));o.setCurrentStatus("INBOUND_COMPLETED");return o;}); + when(fees.external(any())).thenReturn(Map.of("101",freight(),"102",freight())); + when(execution.recordArrangement(any(),any(),any(),any(),anyBoolean())).thenAnswer(a->{ArrangementFact f=a.getArgument(3);CommandIdentity i=a.getArgument(2); + return new CommandResult("DP-10",Action.ARRANGE_DELIVERY,i.idempotencyKey(),4,Status.ACTIVE,Stage.AFTER_LOADING,DEPARTED, + a.getArgument(4)?Effect.EXISTING:Effect.APPLIED,context.counts(),f.result(),false,a.getArgument(4));}); + } + + /** 两客户三个目的地形成两出库三派送,全部子单先于任何来源锁。 */ + @Test void createsCompleteCustomerArraysAndLocksSourcesOnlyAfterEveryChild() { + var result=service.arrange(actor,"DP-10",request("KEY-1")); + var payload=(ArrangementResult)result.result(); + assertEquals(2,payload.outbounds().size());assertEquals(3,payload.deliveries().size()); + assertEquals(9,payload.deliveries().stream().mapToLong(DeliveryReference::packageCount).sum()); + assertEquals(2,payload.deliveries().stream().filter(d->d.destination().warehouseCode().equals("ONT8")).map(DeliveryReference::customerId).distinct().count()); + assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY")); + assertTrue(outbounds.stream().allMatch(o->"WAIT_OUTBOUND".equals(o.getStatus()))); + var items=org.mockito.ArgumentCaptor.forClass(OutboundPalletItem.class);verify(repository,times(3)).create(items.capture()); + assertEquals(java.util.Set.of(1L,2L,3L),items.getAllValues().stream().map(OutboundPalletItem::getSourceAttributionId).collect(java.util.stream.Collectors.toSet())); + assertTrue(items.getAllValues().stream().allMatch(i->i.getSourceOrderId()!=null&&i.getSourceInventoryPositionId()!=null&&i.getDispatchItemId()!=null)); + verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString()); + } + + /** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */ + @Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception { + var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); + var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null); + var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null); + var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L); + context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second), + relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false); + var result=(ArrangementResult)service.arrange(actor,"DP-10",request("RELABEL-ARRANGE")).result(); + assertEquals(2,result.outbounds().size()); + assertEquals(3,result.deliveries().size()); + var ont8=result.deliveries().stream().filter(d->d.customerId().equals("101")&&d.destination().warehouseCode().equals("ONT8")) + .findFirst().orElseThrow(); + assertEquals(5,ont8.packageCount());assertEquals(1,ont8.palletCount()); + assertEquals(2,result.deliveries().stream().filter(d->d.destination().warehouseCode().equals("ONT8")) + .map(DeliveryReference::customerId).distinct().count()); + List cargo=json.readValue(arrangement.getCargoJson(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); + assertEquals(context.cargo(),cargo); + var created=org.mockito.ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(repository,times(4)).create(created.capture()); + for (var item:created.getAllValues()) { + var source=cargo.stream().filter(c->c.customerId().equals(item.getCustomerId()) + && c.attributionId().equals(item.getSourceAttributionId())).findFirst().orElseThrow(); + assertEquals(source.orderId(),item.getSourceOrderId()); + assertNotEquals(source.sourceOrigin().sourceInboundOrderId(),item.getSourceOrderId()); + assertEquals(source.dispatchItemId(),item.getDispatchItemId()); + assertEquals(source.packages(),item.getPackageCount().longValue()); + assertNull(item.getGrossWeightKg());assertNull(item.getVolumeCbm()); + } + assertEquals(3,cargo.stream().filter(c->c.sourceOrigin().productQuantity()==null).count()); + assertTrue(outbounds.stream().allMatch(o->o.getGrossWeightKg()==null&&o.getVolumeCbm()==null)); + assertTrue(deliveries.stream().allMatch(d->d.getTotalWeightKg()==null&&d.getTotalVolumeCbm()==null)); + var orders=org.mockito.ArgumentCaptor.forClass(Long.class); + verify(repository,times(4)).lockSource(anyString(),orders.capture()); + assertEquals(List.of(1L,2L,3L,4L),orders.getAllValues()); + } + + /** 原命令恢复先于新阶段和版本判断,不能再次写任何客户单据。 */ + @Test void strictReplayDelegatesToAuthoritativeLedgerBeforeCreatingDocuments() { + var snapshot=new CommandResult("DP-10",Action.ARRANGE_DELIVERY,"KEY-1",4,Status.ACTIVE,Stage.AFTER_LOADING, + DEPARTED,Effect.APPLIED,context.counts(),null,true,false); + when(execution.replay(any(),any(),any())).thenReturn(Optional.of(snapshot)); + assertSame(snapshot,service.arrange(actor,"DP-10",request("KEY-1"))); + verify(execution,never()).requireFreshArrangement(any(),anyLong()); + verifyNoInteractions(repository,fees); + } + + /** 少一客户、错组或过期货物摘要均拒绝,不产生部分单据。 */ + @Test void incompleteGroupSetAndChangedCargoCannotCreatePartialTruck() { + var full=request("KEY-1"); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.arrange(actor,"DP-10", + new ArrangeDelivery(3L,"KEY-1",full.cargoHash(),full.loadingCompletionHash(),full.groups().subList(0,2)))); + assertTrue(outbounds.isEmpty()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.arrange(actor,"DP-10", + new ArrangeDelivery(3L,"KEY-2","old-hash",full.loadingCompletionHash(),full.groups()))); + assertTrue(deliveries.isEmpty()); + } + + /** 发车只更改单据,所有客户仍共享调用方事务,不提供第二次库存扣减入口。 */ + @Test void departureMarksEveryCustomerDocumentWithoutNewFeePublication() { + service.arrange(actor,"DP-10",request("KEY-1"));clearInvocations(fees); + var result=service.markDeparted(context,actor,DEPARTED,"DEPART-1"); + assertEquals(9,result.shippedPackages());assertEquals(2,result.outbounds().size()); + assertTrue(outbounds.stream().allMatch(o->"OUTBOUNDED".equals(o.getStatus()))); + assertTrue(deliveries.stream().allMatch(d->"DELIVERING".equals(d.getStatus())&&d.getDispatchedAt()!=null)); + verifyNoInteractions(fees); + } + + /** 使用主流程已冻结的Long版本,缺字段不得被当成有效零。 */ + @Test void publicVersionRequiresPresenceAndAcceptsExplicitZero() { + assertThrows(IllegalArgumentException.class,()->new ArrangeDelivery(null,"K","cargo","load",List.of())); + assertThrows(IllegalArgumentException.class,()->new ArrangeDelivery(-1L,"K","cargo","load",List.of())); + assertEquals(Long.valueOf(0),new ArrangeDelivery(0L,"K","cargo","load",List.of()).recordVersion()); + } + + /** 请求依照服务端实际共享DTO构造,不给客户或操作者字段。 */ + private ArrangeDelivery request(String key) { + return new ArrangeDelivery(3L,key,"cargo-hash","loading-hash",grouping.groups(context).stream().map(g->new ArrangeGroup(g.key(),g.cargoHash(), + DEPARTED.plusSeconds(3600),"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",g.cargoHash(),List.of(),List.of())).toList()); + } + /** 本例费用域独立校验,外接报价的实际来源规则另有直接测试。 */ + private FleetBookingFreight.Preview freight() { return new FleetBookingFreight.Preview("freight",true,List.of(),List.of()); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java index 35cd2559a..ce4b1c444 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java @@ -14,13 +14,11 @@ import static org.assertj.core.api.Assertions.*; class DispatchFeeCargoMapperTest { /** 原退回实收只重派其已冻结货量,不借同托其他货物放大来源。 */ @Test void returnedReceiptRetainsItsExactSourceInsteadOfBecomingAWholePallet() { - var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L); item.setReturnReceiptId(701L); item.setSourceStage("RECEIVED"); item.setInboundCode("IN-A"); item.setPackageCount(2); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8"); - var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( - new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper)); - assertThat(query.sources(dispatch, java.util.List.of(item))).singleElement().satisfies(source -> { + var query = nativeGateway(item); + assertThat(query.sourceCargo("DP-1", "101")).singleElement().satisfies(source -> { assertThat(source.inboundCode()).isEqualTo("IN-A"); assertThat(source.packages()).isEqualTo(2); }); } @@ -29,17 +27,28 @@ class DispatchFeeCargoMapperTest { @org.junit.jupiter.params.provider.ValueSource(strings={"IN-A", "IN-B"}) void actualSourceQueryDoesNotDependOnWhichInboundWasChosenForDisplay(String displayInbound) { jdbc.update("UPDATE ll_dispatch_item SET inbound_code=?,plan_pallet_id=801", displayInbound); - var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L); item.setInboundCode(displayInbound); item.setPackageCount(20); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8"); - var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( - new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper)); - var sources = query.sources(dispatch, java.util.List.of(item)); + var query = nativeGateway(item); + var sources = query.sourceCargo("DP-1", "101"); assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::inboundCode).containsExactly("IN-A", "IN-B"); assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::packages).containsExactly(10, 10); item.setPackageCount(19); - assertThatThrownBy(() -> query.sources(dispatch, java.util.List.of(item))).hasMessageContaining("箱数不一致"); + assertThatThrownBy(() -> query.sourceCargo("DP-1", "101")).hasMessageContaining("箱数未核平"); } + /** 使用当前Fleet整车来源入口,隔离无关资源写链,不保留旧客户主单查询类。 */ + private com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter nativeGateway(com.baiyue.lastleg.dispatch.entity.DispatchItem item) { + var aggregates = org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository.class); + var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchAggregate(); + dispatch.setId(1L); dispatch.setDispatchNo("DP-1"); + item.setCustomerId("101"); item.setDispatchOrderId(1L); + org.mockito.Mockito.when(aggregates.find("DP-1")).thenReturn(dispatch); + org.mockito.Mockito.when(aggregates.cargo(1L)).thenReturn(java.util.List.of(item)); + return new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(aggregates,null,null,null, + new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper),null, + new com.fasterxml.jackson.databind.ObjectMapper()); + } + JdbcTemplate jdbc; DispatchFeeCargoMapper mapper; @BeforeEach void setup() throws Exception { var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); @@ -48,7 +57,7 @@ class DispatchFeeCargoMapperTest { jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)"); jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))"); jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))"); - jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(64),dispatch_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(task_id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT)"); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)"); @@ -56,7 +65,7 @@ class DispatchFeeCargoMapperTest { jdbc.update("INSERT INTO ll_inventory_position VALUES(101,'101',501,'WH-1',301,8),(102,'101',501,'WH-1',302,6),(103,'202',501,'WH-1',303,50),(104,'101',501,'WH-2',301,50)"); jdbc.update("INSERT INTO ll_inventory_balance VALUES(301,'101',401,'IN-A'),(302,'101',402,'IN-B'),(303,'202',403,'IN-OTHER')"); jdbc.update("INSERT INTO ll_order VALUES(401,'101','FIRSTLEG','11'),(402,'101','FIRSTLEG','12'),(403,'202','FIRSTLEG','13')"); - jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,'101',1),(602,'101',2)"); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,1),(602,2)"); jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'101',201,101,2),(601,'101',201,102,4),(602,'101',201,101,50)"); var config = new MybatisConfiguration(); config.addMapper(DispatchFeeCargoMapper.class); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet(); @@ -69,6 +78,17 @@ class DispatchFeeCargoMapperTest { assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L); assertThat(mapper.list("202", 1)).isEmpty(); } + /** 同一整车与任务内按真实明细和拣货客户分别统计,不借其他客户货量。 */ + @Test void sharedDispatchAndTaskKeepEachCustomersSourcesSeparate() { + jdbc.update("INSERT INTO ll_dispatch_item VALUES(202,1,'202',501,'PLT-501','WH-1',NULL,'IN-OTHER')"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'202',202,103,7)"); + assertThat(mapper.list("101",1)).extracting(row -> row.packages()).containsExactly(10L,10L); + assertThat(mapper.list("202",1)).singleElement().satisfies(row -> { + assertThat(row.inboundCode()).isEqualTo("IN-OTHER"); + assertThat(row.packages()).isEqualTo(57L); + }); + assertThat(mapper.list("303",1)).isEmpty(); + } @Test void aPhysicalSharedPalletIncludesEveryActualSourceRegardlessOfItsDisplayPlan() { jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/NormalDeliveryCompletionPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/NormalDeliveryCompletionPolicyTest.java new file mode 100644 index 000000000..19a959513 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/NormalDeliveryCompletionPolicyTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.List; +import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*; +import static org.junit.jupiter.api.Assertions.*; + +class NormalDeliveryCompletionPolicyTest { + private final DeliveryCargoGroups groups=new DeliveryCargoGroups(new ObjectMapper().findAndRegisterModules()); + private final NormalDeliveryCompletionPolicy policy=new NormalDeliveryCompletionPolicy(groups); + /** 只有全部客户原实发都正常签收才完成,取最后实际签收时间。 */ + @Test void partialAndReturnDoNotCompleteButAllNormalDoes() { + var context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),true); + var expected=new ArrangementResult("DP-10",3,"ARR-1","cargo-hash",DEPARTED.minusSeconds(30),List.of(),List.of( + reference("101","G1","OUT-A","DL-A",2),reference("102","G2","OUT-B","DL-B",3))); + var first=fact("101","G1","OUT-A","DL-A",2,2,0,"DELIVERED",10); + var refused=fact("102","G2","OUT-B","DL-B",3,0,3,"RETURNED",0); + assertFalse(policy.evaluate(context,expected,List.of(first,refused),DEPARTED.plusSeconds(100)).allNormallySigned()); + var last=fact("102","G2","OUT-B","DL-B",3,3,0,"DELIVERED",20); + var result=policy.evaluate(context,expected,List.of(last,first),DEPARTED.plusSeconds(100)); + assertTrue(result.allNormallySigned());assertEquals(DEPARTED.plusSeconds(20),result.completedAt()); + assertFalse(policy.evaluate(context,expected,List.of(first),DEPARTED.plusSeconds(100)).allNormallySigned()); + assertFalse(policy.evaluate(context,expected,List.of(first,first),DEPARTED.plusSeconds(100)).allNormallySigned()); + } + /** 原签收完成后另退不抹去原运输事实,签剩余则不算全签。 */ + @Test void laterReturnPreservesOriginalSignatureAndCustomerCannotBeSwapped() { + var context=context(List.of(cargo(1,"101","ONT8",51,2)),true); + var expected=new ArrangementResult("DP-10",3,"ARR-1","cargo-hash",DEPARTED,List.of(),List.of(reference("101","G1","OUT-A","DL-A",2))); + assertTrue(policy.evaluate(context,expected,List.of(fact("101","G1","OUT-A","DL-A",2,2,1,"RETURN_PENDING",10)),DEPARTED.plusSeconds(100)).allNormallySigned()); + assertFalse(policy.evaluate(context,expected,List.of(fact("101","G1","OUT-A","DL-A",2,1,1,"DELIVERED",10)),DEPARTED.plusSeconds(100)).allNormallySigned()); + assertFalse(policy.evaluate(context,expected,List.of(fact("102","G1","OUT-A","DL-A",2,2,0,"DELIVERED",10)),DEPARTED.plusSeconds(100)).allNormallySigned()); + assertFalse(policy.evaluate(context,expected,List.of(fact("101","G1","OUT-A","DL-A",2,2,0,"DELIVERED",-1)),DEPARTED.plusSeconds(100)).allNormallySigned()); + } + /** 缺少安排或者空集合不能推断正常完成。 */ + @Test void emptyNeverCompletes() { assertFalse(policy.evaluate(context(List.of(),true),null,List.of(),DEPARTED).allNormallySigned()); } + /** 创建不可变期望派送集合。 */ + private DeliveryReference reference(String customer,String group,String outbound,String no,long packages) { + return new DeliveryReference(group,customer,null,outbound,no,cargo(1,customer,"ONT8",51,packages).destination(),"WAIT_DELIVERY",packages,1); + } + /** 合成签收字段,不写真实业务环境。 */ + private NormalDeliveryFact fact(String customer,String group,String outbound,String no,long original,long signed,long returned,String status,int seconds) { + return new NormalDeliveryFact(customer,group,outbound,no,1,original,signed,returned,0,status,true,signed==0?null:DEPARTED.plusSeconds(seconds)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index 825c3e312..fb1243dd1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -1,12 +1,15 @@ package com.baiyue.lastleg.delivery; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.dispatch.controller.DispatchController; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.controller.DispatchAggregateController; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper; import com.baiyue.lastleg.security.OverseasPermissions; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; @@ -87,13 +90,15 @@ class PlatformDispatchOutboundContractTest { } } + /** 正式整车动作保留独立权限,取消改为携带版本和命令键的POST。 */ @Test void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception { Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class, - String.class, DispatchRequests.Update.class); - Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class); + String.class, DispatchCommands.Update.class); + Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class, + String.class, DispatchCommands.ReasonCommand.class); Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class, - String.class, DeliveryRequests.Transfer.class); + String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class); Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class); assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST); Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail", @@ -103,6 +108,11 @@ class PlatformDispatchOutboundContractTest { assertPermission(update, OverseasPermissions.DISPATCH_EDIT); assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL); + var cancelRoute = cancel.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class); + assertNotNull(cancelRoute); + assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value())); + assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class)); + assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class)); assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY); assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST); assertEquals(2, outboundDetail.getParameterCount()); @@ -112,10 +122,13 @@ class PlatformDispatchOutboundContractTest { .noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class))); } assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) - .noneMatch(method -> "depart".equals(method.getName()))); - assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName", - "departAt", "fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"), - Arrays.stream(DispatchRequests.Update.class.getRecordComponents()) + .noneMatch(method -> Set.of("depart", "cancel").contains(method.getName()))); + assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver", + "plannedDepartureAt", "estimatedArrivalAt", "remark"), + Arrays.stream(DispatchCommands.Update.class.getRecordComponents()) + .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); + assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"), + Arrays.stream(DispatchCommands.ReasonCommand.class.getRecordComponents()) .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); } @@ -147,28 +160,32 @@ class PlatformDispatchOutboundContractTest { assertFalse(deliveryCustomer.required()); } + /** 即使提供完整版本化取消载荷,客户身份也必须在调用整车业务服务前被拒绝。 */ @Test void platformActionsRejectMerchantIdentityBeforeBusinessLookup() { AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB", Set.of()); - DispatchController dispatchController = new DispatchController(); + var aggregate = mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class); + DispatchAggregateController dispatchController = new DispatchAggregateController(aggregate); DeliveryTransferController deliveryController = new DeliveryTransferController(); - ResponseStatusException dispatchRejected = org.junit.jupiter.api.Assertions.assertThrows( - ResponseStatusException.class, - () -> dispatchController.cancel(merchant, "DSP-101")); + BizException dispatchRejected = org.junit.jupiter.api.Assertions.assertThrows( + BizException.class, + () -> dispatchController.cancel(merchant, "DSP-101", + new DispatchCommands.ReasonCommand(0L, "merchant-cancel", "客户不可取消整车"))); ResponseStatusException outboundRejected = org.junit.jupiter.api.Assertions.assertThrows( ResponseStatusException.class, () -> deliveryController.outboundDetail(merchant, "OUT-101")); - assertEquals(403, dispatchRejected.getStatusCode().value()); + assertEquals(ResultCode.FORBIDDEN, dispatchRejected.getResultCode()); + org.mockito.Mockito.verifyNoInteractions(aggregate); assertEquals(403, outboundRejected.getStatusCode().value()); } @Test void globalBusinessNumberMappersAndSchemaDoNotUseCustomerForIdentity() throws Exception { - String dispatchFind = select(DispatchOrderMapper.class, "findGlobalByDispatchNo", String.class); - String dispatchLock = select(DispatchOrderMapper.class, "lockGlobalByDispatchNo", String.class); + String dispatchFind = select(DispatchAggregateMapper.class, "find", String.class); + String dispatchLock = select(DispatchAggregateMapper.class, "lock", String.class); String outboundFind = select(OutboundOrderMapper.class, "findGlobalByNo", String.class); String outboundLock = select(OutboundOrderMapper.class, "lockGlobalByNo", String.class); @@ -185,6 +202,7 @@ class PlatformDispatchOutboundContractTest { assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)")); } + /** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */ @Test void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception { String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), @@ -214,7 +232,24 @@ class PlatformDispatchOutboundContractTest { assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel")); - assertTrue(yaml.contains("vehicleSize: { type: string, nullable: true, enum: [15托, 30托] }")); + java.util.Map document = new org.yaml.snakeyaml.Yaml().load(yaml); + java.util.Map schemas = (java.util.Map)((java.util.Map)document.get("components")).get("schemas"); + java.util.Map arrange = (java.util.Map)schemas.get("DispatchArrangeDelivery"); + assertNotNull(arrange); + assertTrue(((java.util.List)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey"))); + java.util.Map fields = (java.util.Map)arrange.get("properties"); + assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups"))); + assertFalse(fields.containsKey("customerId")); + assertFalse(fields.containsKey("expectedVersion")); + java.util.Map groups = (java.util.Map)fields.get("groups"); + assertEquals("array", groups.get("type")); + assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map)groups.get("items")).get("$ref")); + java.util.Map groupFields = (java.util.Map)((java.util.Map)schemas.get("DispatchArrangeGroup")).get("properties"); + assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage"))); + java.util.Map resultFields = (java.util.Map)((java.util.Map)schemas.get("DispatchArrangementResult")).get("properties"); + assertEquals("array", ((java.util.Map)resultFields.get("outbounds")).get("type")); + assertEquals("array", ((java.util.Map)resultFields.get("deliveries")).get("type")); + assertFalse(schemas.containsKey("DeliveryTransfer")); } private static void assertPermission(Method method, String permission) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java index 37ff4394f..9087f3f21 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java @@ -18,13 +18,14 @@ import static org.assertj.core.api.Assertions.*; /** Two real database transactions execute the production mapper SQL, with bounded waits. */ class SourceDeliveryCompletionConcurrencyTest { + /** 两个真实签收事务共享来源锁,最后持锁者必须读到前一签收提交。 */ @Test void lastSignerSeesFirstCommitEvenAfterAnEarlyReadWithoutLockingOtherDelivery() throws Exception { var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000","sa",""); var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds); db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); - db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,status VARCHAR(20),stage VARCHAR(30),departed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); - db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)"); db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); @@ -33,9 +34,9 @@ class SourceDeliveryCompletionConcurrencyTest { db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S',10)"); for (int i=1;i<=2;i++) { - db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-10-03 01:00:00')",i); + db.update("INSERT INTO ll_dispatch_order(id,status,stage,departed_at) VALUES(?,'ACTIVE','DELIVERING','2026-10-03 01:00:00')",i); db.update("INSERT INTO ll_delivery_order VALUES(?,'C',?,'CODE:A',5,NULL,NULL,?)",i,i,"OUT-"+i); - db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",i,i); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id) VALUES(?,?)",i,i); db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",i,i); db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",i,i,i); db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,101,5,?,NULL)",i,i,i); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java index 9b187676e..69a8fce85 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java @@ -6,7 +6,6 @@ import com.baiyue.lastleg.delivery.service.DeliveryTransferService; import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; import com.baiyue.lastleg.security.OverseasPermissions; -import jakarta.validation.Validation; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.test.util.ReflectionTestUtils; @@ -15,7 +14,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.server.ResponseStatusException; import java.lang.reflect.Method; -import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -26,7 +24,6 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -41,14 +38,12 @@ class MerchantDeliveryControllerTest { 1, 20, null, null, null, null, null, null, null, null, null, null, null, null); @Test - void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception { + void fixedRoutesKeepOnlyTokenScopedReads() throws Exception { RequestMapping mapping = MerchantDeliveryController.class.getAnnotation(RequestMapping.class); assertEquals(List.of("/api/merchant/v1/last-leg/delivery-orders"), List.of(mapping.value())); Method page = MerchantDeliveryController.class.getMethod("page", AuthenticatedUser.class, DeliveryRequests.Query.class); - Method create = MerchantDeliveryController.class.getMethod("create", AuthenticatedUser.class, - String.class, DeliveryRequests.Transfer.class); Method detail = MerchantDeliveryController.class.getMethod("detail", AuthenticatedUser.class, String.class); Method receipts = MerchantDeliveryController.class.getMethod("receipts", AuthenticatedUser.class, String.class); Method download = MerchantDeliveryController.class.getMethod("receiptDownload", AuthenticatedUser.class, String.class, long.class); @@ -57,30 +52,23 @@ class MerchantDeliveryControllerTest { assertPermission(detail, OverseasPermissions.MERCHANT_DELIVERY_LIST); assertPermission(receipts, OverseasPermissions.MERCHANT_DELIVERY_LIST); assertPermission(download, OverseasPermissions.MERCHANT_DELIVERY_LIST); - assertPermission(create, OverseasPermissions.MERCHANT_DELIVERY_CREATE); - assertNotEquals(OverseasPermissions.MERCHANT_DELIVERY_LIST, - OverseasPermissions.MERCHANT_DELIVERY_CREATE); assertTrue(Arrays.stream(page.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(detail.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(receipts.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(download.getParameters()).noneMatch(this::isCustomerParameter)); - assertTrue(Arrays.stream(create.getParameters()).noneMatch(this::isCustomerParameter)); - assertTrue(Arrays.stream(DeliveryRequests.Transfer.class.getRecordComponents()) - .noneMatch(component -> "customerId".equals(component.getName()))); + assertTrue(Arrays.stream(MerchantDeliveryController.class.getDeclaredMethods()) + .noneMatch(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class))); } @Test void allOperationsUseOnlyAuthenticatedCustomerScope() { AuthenticatedUser merchant = user(100L, "MERCHANT"); - DeliveryRequests.Transfer request = transferRequest(); controller.page(merchant, query); controller.detail(merchant, "DEL-100"); - controller.create(merchant, "DSP-100", request); verify(service).deliveryPage("100", query); verify(service).deliveryDetail("100", "DEL-100"); - verify(service).merchantTransfer("100", "DSP-100", "1", request); } @Test @@ -89,7 +77,6 @@ class MerchantDeliveryControllerTest { assertThrows(ResponseStatusException.class, () -> controller.page(platform, query)); assertThrows(ResponseStatusException.class, () -> controller.detail(platform, "DEL-100")); - assertThrows(ResponseStatusException.class, () -> controller.create(platform, "DSP-100", transferRequest())); assertThrows(ResponseStatusException.class, () -> controller.receipts(platform, "DEL-100")); assertThrows(ResponseStatusException.class, () -> controller.receiptDownload(platform, "DEL-100", 13)); } @@ -122,29 +109,35 @@ class MerchantDeliveryControllerTest { org.mockito.Mockito.verifyNoInteractions(documents); } + /** 客户即使保留旧权限或原创建载荷,也不能通过已移除的写路由安排整车。 */ @Test - void invalidCreatePayloadIsRejectedByBeanValidationContract() { - DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null, - " ", " ", null, null, null, null, null, null, null, null, null, null, null, null); - try (var factory = Validation.buildDefaultValidatorFactory()) { - assertTrue(factory.getValidator().validate(invalid).size() >= 6); - } + void removedCreateRouteReturnsMethodNotAllowedWithoutCallingBusinessService() throws Exception { + var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .post("/api/merchant/v1/last-leg/delivery-orders") + .param("dispatchNo", "DP-1").contentType("application/json") + .content("{\"idempotencyKey\":\"old-client\",\"fees\":[]}")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isMethodNotAllowed()); + org.mockito.Mockito.verifyNoInteractions(service, documents); } + /** 精确核对客户派送路径,其他业务的合法写操作不得混入本合同。 */ @Test void openApiPublishesOnlyTokenScopedMerchantDeliveryProtocol() throws Exception { String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), StandardCharsets.UTF_8); - String contract = slice(yaml, " /api/merchant/v1/last-leg/delivery-orders:", - " /api/merchant/v1/last-leg/overseas-procurements:"); + String contract = pathBlock(yaml, "/api/merchant/v1/last-leg/delivery-orders") + + pathBlock(yaml, "/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}"); assertTrue(contract.contains("merchant:warehouse:delivery:list")); - assertTrue(contract.contains("merchant:warehouse:delivery:create")); + assertFalse(contract.contains("merchant:warehouse:delivery:create")); + assertFalse(contract.contains("\n post:")); assertFalse(contract.contains("name: customerId")); String receiptContract = Files.readString(Path.of("src/main/resources/openapi/delivery-documents-v1.yaml")); - String merchantPaths = slice(receiptContract, - " /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt:", - " /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents:"); + String merchantPaths = pathBlock(receiptContract, + "/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt") + + pathBlock(receiptContract, + "/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt/files/{fileId}/download"); assertTrue(merchantPaths.contains("merchant:warehouse:delivery:list")); assertTrue(merchantPaths.contains("/documents/receipt/files/{fileId}/download:")); assertFalse(merchantPaths.contains("#/components/parameters/CustomerId")); @@ -152,6 +145,41 @@ class MerchantDeliveryControllerTest { assertFalse(merchantPaths.contains("\n put:")); } + /** 相邻仓储业务保留POST时仍只提取派送路径,派送本身的POST不能被提取器漏掉。 */ + @Test + void exactPathBlocksExcludeUnrelatedWritesAndKeepDeliveryWritesDetectable() { + String yaml = """ + paths: + /api/merchant/v1/last-leg/delivery-orders: + get: + summary: 客户派送列表 + /api/merchant/v1/last-leg/warehouse-transfers: + post: + summary: 合法仓储转运创建 + /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}: + get: + summary: 客户派送详情 + /api/merchant/v1/last-leg/cargo-discards: + post: + summary: 合法弃货创建 + components: + schemas: + post: {} + """; + String contract = pathBlock(yaml, "/api/merchant/v1/last-leg/delivery-orders") + + pathBlock(yaml, "/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}"); + assertTrue(contract.contains("客户派送列表")); + assertTrue(contract.contains("客户派送详情")); + assertFalse(contract.contains("warehouse-transfers")); + assertFalse(contract.contains("cargo-discards")); + assertFalse(contract.contains("components:")); + assertFalse(contract.contains("\n post:")); + String illegalDeliveryWrite = yaml.replace(" get:\n summary: 客户派送列表", + " post:\n summary: 客户派送列表"); + assertTrue(pathBlock(illegalDeliveryWrite, "/api/merchant/v1/last-leg/delivery-orders") + .contains("\n post:")); + } + private boolean isCustomerParameter(java.lang.reflect.Parameter parameter) { RequestParam annotation = parameter.getAnnotation(RequestParam.class); return annotation != null && ("customerId".equals(annotation.name()) @@ -163,11 +191,15 @@ class MerchantDeliveryControllerTest { assertEquals("hasAuthority('" + permission + "')", annotation.value()); } - private static String slice(String source, String start, String end) { - int from = source.indexOf(start); - int to = source.indexOf(end, from + start.length()); - assertTrue(from >= 0 && to > from); - return source.substring(from, to); + /** 按精确路径头定位,只读取到下一同级路径或根章节,避免跨业务批量截取。 */ + private static String pathBlock(String source, String path) { + var header = java.util.regex.Pattern.compile("(?m)^ " + + java.util.regex.Pattern.quote(path) + ":[ \\t]*\\r?$").matcher(source); + assertTrue(header.find(), "缺少OpenAPI路径:" + path); + var next = java.util.regex.Pattern.compile("(?m)^(?: /[^\\r\\n]*:|[A-Za-z][A-Za-z0-9_-]*:)[ \\t]*\\r?$") + .matcher(source); + int to = next.find(header.end()) ? next.start() : source.length(); + return source.substring(header.start(), to); } private static AuthenticatedUser user(long tenantId, String appCode) { @@ -181,11 +213,4 @@ class MerchantDeliveryControllerTest { return value; } - private static DeliveryRequests.Transfer transferRequest() { - return new DeliveryRequests.Transfer("merchant-key", LocalDateTime.now(), "SELF_DELIVERY", null, null, - "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null, - List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY", - new BigDecimal("10.00"), null, null, null, null, null, null, null, null, - null, null, null, null, null, null)), null, null, null); - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index 8e9bbe555..ff6f4eb11 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -42,22 +42,20 @@ class DeliveryDocumentServiceTest { @Mock CustomerVisitService customerVisits; @Mock SourceDeliveryCompletionQuery sourceCompletion; @Mock DeliverySourceEvidenceMapper sourceEvidence; + @Mock com.baiyue.lastleg.dispatch.port.DispatchSignalPort signals; DeliveryDocumentService service; DeliveryOrder order; @BeforeEach void setUp() { service = new DeliveryDocumentService(); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); - ReflectionTestUtils.setField(service,"orders",orders); - ReflectionTestUtils.setField(service,"outboundOrders",outboundOrders); - ReflectionTestUtils.setField(service,"revisions",revisions); - ReflectionTestUtils.setField(service,"operations",operations); + ReflectionTestUtils.setField(service,"repository",new com.baiyue.lastleg.delivery.repository.DeliveryDocumentRepository( + orders,outboundOrders,revisions,operations,lastlegOrders,sourceEvidence)); + ReflectionTestUtils.setField(service,"signals",signals); ReflectionTestUtils.setField(service,"files",files); ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); - ReflectionTestUtils.setField(service,"lastlegOrders",lastlegOrders); ReflectionTestUtils.setField(service,"deliveryCompletedOutbox",deliveryCompletedOutbox); ReflectionTestUtils.setField(service,"customerVisits",customerVisits); ReflectionTestUtils.setField(service,"sourceCompletion",sourceCompletion); - ReflectionTestUtils.setField(service,"sourceEvidence",sourceEvidence); order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); order.setOutboundNo("OUT-1"); order.setPackageCount(10); @@ -250,7 +248,7 @@ class DeliveryDocumentServiceTest { verifyNoInteractions(revisions,operations); } @Test void pendingSourceAuditUsesBusinessLabelAndChineseReasonWithoutInternalIdentifiers() { - order.setStatus("DELIVERING");order.setDispatchOrderId(50L);order.setDeliveryGroupKey("CODE:A"); + order.setStatus("DELIVERING");order.setDispatchOrderId(50L);order.setDispatchNo("DP-50");order.setDeliveryGroupKey("CODE:A"); when(orders.lockByNo("100","DL-1")).thenReturn(order); when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); var outbound=new OutboundOrder();outbound.setStatus("OUTBOUNDED");when(outboundOrders.findByNo("100","OUT-1")).thenReturn(outbound); @@ -259,6 +257,7 @@ class DeliveryDocumentServiceTest { when(lastlegOrders.selectBatchIds(anyCollection())).thenReturn(List.of(tail));when(lastlegOrders.selectById(20L)).thenReturn(tail); when(sourceCompletion.proveAll(eq("100"),anyCollection())).thenReturn(java.util.Map.of("S",new SourceDeliveryCompletionPolicy.Result("S",10,6,false,List.of("DELIVERY_SOURCE_COVERAGE_UNRESOLVED:987654")))); service.save("100","DL-1","9",request(0,"audit",List.of(new Binding(RECEIPT,List.of(13L))))); + verify(signals).enqueue(eq("DP-50"),eq("DL-1"),eq(1L),any()); var operationsSaved=ArgumentCaptor.forClass(DeliveryOperation.class);verify(operations,times(3)).insert(operationsSaved.capture()); var text=operationsSaved.getAllValues().stream().filter(o->"FIRSTLEG_COMPLETION_PENDING".equals(o.getOperationNode())).findFirst().orElseThrow().getResult(); assertThat(text).contains("—","6/10","来源证据尚未核齐").doesNotContain("987654","123456","UNRESOLVED","null"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index d2684922e..b7a5b9034 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -15,9 +15,9 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; @@ -69,6 +69,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -98,7 +99,7 @@ class DeliveryTransferServiceTest { .valueToTree(detail).path("task").path("outboundType").asText()); } - @Mock DispatchOrderMapper dispatchOrders; + @Mock DispatchAggregateRepository dispatches; @Mock DispatchItemMapper dispatchItems; @Mock OutboundOrderMapper outboundOrders; @Mock OutboundPalletItemMapper outboundPallets; @@ -127,30 +128,19 @@ class DeliveryTransferServiceTest { @Mock com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock; private DeliveryTransferService service; - @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; - @Mock com.baiyue.lastleg.fleet.trip.FleetBookingFreight bookingFreight; - @Mock DeliveryFeeCoverageService feeCoverage; + @Mock com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; @BeforeEach void setUp() { service = new DeliveryTransferService(); ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders))); - var actualSources = mock(DispatchCargoSourceQuery.class); - ReflectionTestUtils.setField(service, "cargoSources", actualSources); - org.mockito.Mockito.lenient().when(actualSources.sources(any(), any())).thenAnswer(call -> { - List selected = call.getArgument(1); - return selected.stream().map(item -> new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo( - item.getInboundCode(), item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), - java.util.Objects.requireNonNullElse(item.getPackageCount(), 0), com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey( - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()))).toList(); - }); - ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); - ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight); - ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage); - org.mockito.Mockito.lenient().when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) - .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("test-hash", true, List.of(), List.of())); + ReflectionTestUtils.setField(service, "execution", execution); + ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository( + outboundOrders,outboundPallets,deliveryOrders,pdaTasks,deliveryOperations, + mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class),returnCartons, + mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class),tailOrders,histories)); ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); - ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); + ReflectionTestUtils.setField(service, "dispatches", dispatches); ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets); @@ -448,261 +438,31 @@ class DeliveryTransferServiceTest { return carton; } - @Test - void transferPreservesUnknownGrossWeightThroughPalletAndDeliverySnapshots() { - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); - DispatchItem known = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); - DispatchItem unknown = item(202L, 502L, "PLT-502", "ONT8", "Ontario", "IN-B", 8); - unknown.setGrossWeightKg(null); unknown.setVolumeWeightKg(new BigDecimal("999")); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(known, unknown)); - service.platformTransfer("DSP-101", "U-1", transferRequest("unknown-gross", null)); - var outbound = ArgumentCaptor.forClass(OutboundOrder.class); - var delivery = ArgumentCaptor.forClass(DeliveryOrder.class); - var 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(null, outbound.getValue().getGrossWeightKg()); - assertEquals(null, delivery.getValue().getTotalWeightKg()); - assertEquals(null, pallets.getAllValues().get(1).getGrossWeightKg()); - assertEquals(new BigDecimal("999"), pallets.getAllValues().get(1).getVolumeWeightKg()); - } - @Test void transferCannotOverrideAnAssignmentOwnedByTheTrip() { - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); - org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "车辆司机由车次维护")) - .when(fleetBindings).requireMatchingAssignment(org.mockito.ArgumentMatchers.eq(101L), any(), any()); - assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", transferRequest("bound-transfer", null))); - org.mockito.Mockito.verifyNoInteractions(deliveryOrders, feeSources, dispatchItems); - verify(outboundOrders, never()).insert((OutboundOrder) any()); - } - @Test - void firstTransferCreatesOneOutboundAndOneDeliveryPerDestinationFbaWarehouse() { - DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).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.platformTransfer("DSP-101", "U-1", - transferRequest("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, org.mockito.Mockito.times(2)).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(new BigDecimal("20"), outbound.getValue().getGrossWeightKg()); - assertEquals(new BigDecimal("2.0"), outbound.getValue().getVolumeCbm()); - assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); - assertTrue(outbound.getValue().getOutboundNo().matches("OB-WH-\\d{8}-\\d{6}")); - assertTrue(delivery.getAllValues().stream().allMatch(value -> outbound.getValue().getId().equals(value.getOutboundOrderId()))); - assertEquals(List.of("ONT8", "LAX9"), delivery.getAllValues().stream().map(DeliveryOrder::getDestinationWarehouseCode).toList()); - assertEquals(List.of(12, 8), delivery.getAllValues().stream().map(DeliveryOrder::getPackageCount).toList()); - assertTrue(delivery.getAllValues().stream().allMatch(value -> "WAIT_DELIVERY".equals(value.getStatus()))); - 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(List.of(new BigDecimal("12"), new BigDecimal("8")), pallets.getAllValues().stream() - .map(OutboundPalletItem::getGrossWeightKg).toList()); - assertEquals(List.of(new BigDecimal("12"), new BigDecimal("8")), delivery.getAllValues().stream() - .map(DeliveryOrder::getTotalWeightKg).toList()); - assertEquals(20, result.packageCount()); - assertEquals(2, result.palletCount()); - assertEquals(2, result.deliveries().size()); - verify(feeSources, org.mockito.Mockito.times(1)).publishApproved(any(), any(), any()); - assertTrue(!result.replayed()); - } - @Test - void transferBackfillsReceivedPlanPalletsBeforeCheckingDispatchItems() { - DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); - when(dispatchItems.bindReceivedPallets("TENANT-A", 101L)).thenReturn(1); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( - item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); - service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-after-receiving", null)); - var ordered = inOrder(dispatchItems); - ordered.verify(dispatchItems).bindReceivedPallets("TENANT-A", 101L); - ordered.verify(dispatchItems).findByDispatchOrder("TENANT-A", 101L); - } - @Test - void transferCollectsLxCodesWhenAnInboundCodeBelongsToMultipleTailOrders() { - DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( - item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); - LastlegOrder primary = new LastlegOrder(); - primary.setCustomerId("TENANT-A"); primary.setInboundCode("IN-A"); primary.setLxCode("LX-1"); - LastlegOrder derived = new LastlegOrder(); - derived.setCustomerId("TENANT-A"); derived.setInboundCode("IN-A"); derived.setLxCode(null); - when(tailOrders.selectList(any())).thenReturn(List.of(primary, derived), List.of()); - - service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-duplicate-inbound", null)); - - ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); - verify(outboundOrders).insert(outbound.capture()); - assertEquals("LX-1", outbound.getValue().getLxCodes()); - } @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(strings = {"SAME_KEY", "NEW_KEY", "INSERT_RACE"}) - void changedFeesCannotReplayAnEarlierTransferAsSuccessful(String path) throws Exception { - var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( - item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); - var request = transferRequest("transfer-original", null); - service.platformTransfer("DSP-101", "U-1", request); - var created = ArgumentCaptor.forClass(OutboundOrder.class); - var delivered = ArgumentCaptor.forClass(DeliveryOrder.class); - verify(outboundOrders).insert(created.capture()); verify(deliveryOrders).insert(delivered.capture()); - var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); - com.fasterxml.jackson.databind.node.ObjectNode changed = mapper.valueToTree(request); - ((com.fasterxml.jackson.databind.node.ObjectNode) changed.withArray("fees").get(0)).put("amount", new BigDecimal("20.00")); - if ("NEW_KEY".equals(path)) changed.put("idempotencyKey", "another-key"); - var retry = mapper.treeToValue(changed, DeliveryRequests.Transfer.class); - if ("NEW_KEY".equals(path)) when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(created.getValue()); - else if ("INSERT_RACE".equals(path)) { - when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(null, created.getValue()); - when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("race")); - } else when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(created.getValue()); - org.mockito.Mockito.lenient().when(deliveryOrders.lockByOutbound("TENANT-A", created.getValue().getId())).thenReturn(List.of(delivered.getValue())); - var failure = assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", retry)); - assertEquals(409, failure.getStatusCode().value()); - assertTrue(failure.getReason().contains("内容不一致")); - verify(feeSources).publishApproved(any(), any(), any()); - verify(deliveryOrders).insert(any(DeliveryOrder.class)); - } - @Test - void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() { - DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); - var request = transferRequest(" transfer-101 ", null); - outbound.setRequestHash(service.transferRequestHash(request)); - DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); - when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); - DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", - request); - assertTrue(result.replayed()); - assertEquals("OUT-301", result.outboundNo()); - assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); - org.mockito.Mockito.verifyNoInteractions(fleetBindings); - verify(outboundOrders, never()).insert((OutboundOrder) any()); - verify(outboundPallets, never()).insert((OutboundPalletItem) any()); - verify(deliveryOrders, never()).insert((DeliveryOrder) any()); - } - @Test - void identicalBusinessContentWithAnotherCommandKeyReturnsExistingDocumentsWithoutNewFees() throws Exception { - var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - var original = transferRequest("first-key", null); - var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "first-key"); - outbound.setRequestHash(service.transferRequestHash(original)); - var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); - com.fasterxml.jackson.databind.node.ObjectNode content = mapper.valueToTree(original); - content.put("idempotencyKey", "another-key"); - var retry = mapper.treeToValue(content, DeliveryRequests.Transfer.class); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); - when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery(401L, outbound, "DEL-401"))); - var replay = service.platformTransfer("DSP-101", "U-1", retry); - assertTrue(replay.replayed()); assertEquals("OUT-301", replay.outboundNo()); - org.mockito.Mockito.verifyNoInteractions(feeSources, fleetBindings); - verify(outboundOrders, never()).insert(any(OutboundOrder.class)); - } - @Test - void idempotencyKeyCannotBeReusedForAnotherDispatch() { - DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A"); - OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key"); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-102")).thenReturn(dispatch); - when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other); - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.platformTransfer("DSP-102", "U-1", transferRequest("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"); - var request = transferRequest("transfer-101", null); - winner.setRequestHash(service.transferRequestHash(request)); - DeliveryOrder delivery = delivery(401L, winner, "DEL-401"); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).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(List.of(delivery)); - DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", - request); - assertTrue(result.replayed()); - assertEquals("OUT-301", result.outboundNo()); - assertEquals("DEL-401", result.deliveries().get(0).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.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source)); - when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder)); - service.platformTransfer("DSP-101", "U-1", transferRequest("direct-order", null)); - assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus()); - assertNotNull(tailOrder.getOutboundOrderNo()); - assertNotNull(tailOrder.getDeliveryOrderNo()); - verify(tailOrders).updateById(tailOrder); - ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); - verify(histories).insert(history.capture()); - assertEquals("TENANT-A", history.getValue().getCustomerId()); - } - - @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.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null))); - - assertEquals(409, exception.getStatusCode().value()); - assertTrue(exception.getReason().contains("received")); - verify(outboundOrders, never()).insert((OutboundOrder) any()); - } @Test void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() { @@ -775,52 +535,6 @@ class DeliveryTransferServiceTest { verify(deliveryOrders).lockByOutbound("TENANT-A", 301L); } - @Test - void merchantTransferCannotResolveAnotherCustomersDispatch() { - when(dispatchOrders.findByDispatchNo("TENANT-B", "DSP-101")).thenReturn(null); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.merchantTransfer("TENANT-B", "DSP-101", "U-2", transferRequest("merchant-key", null))); - - assertEquals(404, exception.getStatusCode().value()); - verify(dispatchOrders, never()).lockGlobalByDispatchNo(any()); - verify(outboundOrders, never()).insert((OutboundOrder) any()); - } - - @Test - void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() { - DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key"); - var request = transferRequest(" merchant-key ", null); - outbound.setRequestHash(service.transferRequestHash(request)); - 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", "merchant-key")).thenReturn(outbound); - when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery)); - - DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2", - request); - - assertTrue(result.replayed()); - assertEquals("DEL-401", result.deliveries().get(0).deliveryNo()); - verify(outboundOrders, never()).insert((OutboundOrder) any()); - } - - @Test - void merchantTransferRejectsCancelledDispatch() { - DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - dispatch.setStatus("CANCELLED"); - when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); - when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.merchantTransfer("TENANT-A", "DSP-101", "U-2", transferRequest("merchant-key", null))); - - assertEquals(409, exception.getStatusCode().value()); - verify(outboundOrders, never()).insert((OutboundOrder) any()); - } - @Test void merchantDeliveryDetailCannotCrossCustomerBoundary() { when(deliveryOrders.findByNo("TENANT-B", "DEL-401")).thenReturn(null); @@ -949,7 +663,11 @@ class DeliveryTransferServiceTest { .getMethod("deliveryPickedSkus", String.class, Long.class); String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value()); assertTrue(sql.contains("t.dispatch_order_id=#{dispatchId}")); - assertTrue(sql.contains("BINARY t.customer_id=BINARY #{customerId}")); + assertTrue(sql.contains("BINARY p.customer_id=BINARY #{customerId}")); + assertTrue(sql.contains("di.dispatch_order_id=t.dispatch_order_id")); + assertTrue(sql.contains("BINARY di.customer_id=BINARY p.customer_id")); + assertTrue(sql.contains("BINARY a.customer_id=BINARY p.customer_id")); + assertTrue(!sql.contains("t.customer_id")); assertTrue(sql.contains("CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END")); assertTrue(sql.contains("COALESCE(a.packages,p.packages)")); assertTrue(!sql.contains("ll_order_detail")); @@ -998,9 +716,8 @@ class DeliveryTransferServiceTest { com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(new org.apache.ibatis.builder.MapperBuilderAssistant( new com.baomidou.mybatisplus.core.MybatisConfiguration(), "cancellation-test"), LastlegOrder.class); var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key"); - var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PENDING_DEPARTURE"); when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); - when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(execution.lockForArrangement(any(),eq("DSP-101"))).thenReturn(cancellationContext(0)); when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); var pallet = pallet(501L, "P-1", "ONT8", "Ontario", "IN-1", 12); pallet.setStatus("WAIT_PICK"); pallet.setPickedPackageCount(0); when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet)); @@ -1033,17 +750,16 @@ class DeliveryTransferServiceTest { .map(row -> row.getResult().replaceFirst("^\\(\\d+\\) ", "")).collect(java.util.stream.Collectors.joining())); verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(702L), any(), any()); verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(703L), any(), any()); - var ordered = org.mockito.Mockito.inOrder(outboundOrders, dispatchOrders, fleetBindings); - ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(dispatchOrders).lockById("TENANT-A", 101L); - ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); ordered.verify(fleetBindings).requireUnbound(101L); + var ordered = org.mockito.Mockito.inOrder(outboundOrders, execution); + ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(execution).lockForArrangement(any(),eq("DSP-101")); + ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("重试")); verify(feeSources, org.mockito.Mockito.times(1)).voidSource(org.mockito.ArgumentMatchers.anyLong(), any(), any()); } @Test void dispatchPickingCannotBeCancelledWhileOutboundSnapshotStillLooksUnexecuted() { var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key"); - var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PICKING"); dispatch.setPickedPackageCount(1); when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); - when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(execution.lockForArrangement(any(),eq("DSP-101"))).thenReturn(cancellationContext(1)); when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); assertThrows(ResponseStatusException.class, () -> service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("取消"))); org.mockito.Mockito.verifyNoInteractions(feeSources, outboundPallets, deliveryOrders); @@ -1052,6 +768,12 @@ class DeliveryTransferServiceTest { var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); fee.setId(id); fee.setSourceFeeId(id); fee.setSourceVersion(1); fee.setFeeItemCode(type); fee.setDirection(direction); fee.setSourceStatus("ACTIVE"); fee.setReviewStatus("APPROVED"); return fee; } + /** 取消用例使用真实公共整车上下文,不再用单客户头部推断资格。 */ + private static com.baiyue.lastleg.dispatch.port.DispatchContracts.Context cancellationContext(long picked) { + return new com.baiyue.lastleg.dispatch.port.DispatchContracts.Context(101L,"DSP-101",20L,"TRIP-20","WH-US-1",null,null, + com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE,com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING, + 0,1,null,null,null,null,null,new com.baiyue.lastleg.dispatch.port.DispatchContracts.Counts(1,1,12,picked,0),null,false,List.of()); + } @Test void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() { @@ -1246,10 +968,10 @@ class DeliveryTransferServiceTest { OutboundOrder outbound = outbound(301L, 401L, "DSP-401", "OUT-301", "transfer-key"); outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("DISPATCH_DELIVERY"); - DispatchOrder dispatch = new DispatchOrder(); + DispatchAggregate dispatch = new DispatchAggregate(); dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1"); when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); - when(dispatchOrders.findGlobalByDispatchNo("DSP-401")).thenReturn(dispatch); + when(dispatches.find("DSP-401")).thenReturn(dispatch); DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301"); @@ -1258,6 +980,18 @@ class DeliveryTransferServiceTest { assertEquals("OUT-301", result.outboundNo()); } + /** 直接扫排车号使用无客户头整车,并保持PDA绑定仓隔离。 */ + @Test + void directDispatchNumberResolvesOnlyInItsWarehouse() { + DispatchAggregate dispatch = new DispatchAggregate(); + dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1"); + when(dispatches.find("DSP-401")).thenReturn(dispatch); + assertEquals("DSP-401", service.resolvePdaTask("WH-1", "DSP-401").dispatchNo()); + ResponseStatusException denied = assertThrows(ResponseStatusException.class, + () -> service.resolvePdaTask("WH-2", "DSP-401")); + assertEquals(404, denied.getStatusCode().value()); + } + @Test void outboundNumberResolvesOneClickTaskInBoundWarehouse() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); @@ -1536,114 +1270,13 @@ class DeliveryTransferServiceTest { .getStatusCode().value()); } - @Test void externalFreightPublishesOnlyTheConfirmedQuoteOnItsOwnDestination() { - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( - item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12), - item(202L, 502L, "PLT-502", "LAX9", "LA", "WT-2", 8))); - var q1 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", new BigDecimal("60.00"), new BigDecimal("2"), "托位", new BigDecimal("30.00")); - var q2 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-2", 4, "WT-2", "IN-2", "客户甲", "FBA:LAX9", "CNY", BigDecimal.ZERO, BigDecimal.ONE, "单", BigDecimal.ZERO); - when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) - .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("external-hash", false, List.of(q1, q2), List.of("IN-1", "IN-2"))); - var request = externalRequest("external-hash", List.of()); - service.platformTransfer("DSP-101", "OP-1", request); - var deliveries = ArgumentCaptor.forClass(DeliveryOrder.class); - verify(deliveryOrders, org.mockito.Mockito.times(2)).insert(deliveries.capture()); - var fee = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class); - verify(feeSources).publishApproved(org.mockito.ArgumentMatchers.eq("OP-1"), org.mockito.ArgumentMatchers.eq("FLEET:FB-1:DSP-101:FREIGHT"), fee.capture()); - verify(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1", "IN-2")); - assertEquals(new BigDecimal("60.00"), fee.getValue().amount()); - assertEquals("IN-1", fee.getValue().inboundCode()); - assertEquals(deliveries.getAllValues().stream().filter(d -> "ONT8".equals(d.getDestinationWarehouseCode())).findFirst().orElseThrow().getId(), fee.getValue().businessId()); - var outbound = ArgumentCaptor.forClass(OutboundOrder.class); - verify(outboundOrders).insert(outbound.capture()); - when(outboundOrders.findByIdempotency("TENANT-A", "external-1")).thenReturn(outbound.getValue()); - when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(deliveries.getAllValues()); - assertTrue(service.platformTransfer("DSP-101", "OP-OTHER", request).replayed()); - org.mockito.Mockito.verifyNoMoreInteractions(feeSources); - } - @Test void externalFreightRejectsStalePreviewAndDuplicateDeliveryChargeBeforeWriting() { - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12))); - var q = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", BigDecimal.TEN, BigDecimal.ONE, "单", BigDecimal.TEN); - when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) - .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("current-hash", false, List.of(q), List.of("IN-1"))); - assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("old-hash", List.of()))); - assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("current-hash", List.of(receivableDeliveryFee())))); - verify(outboundOrders, never()).insert((OutboundOrder) any()); - org.mockito.Mockito.verifyNoInteractions(feeSources, deliveryOrders); - } - @Test void rebookingAfterCancellationCannotChargeAgainBeforeTheOriginalReceivableIsVoided() { - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "WT-1", 12))); - when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any())) - .thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("cancelled-source", true, List.of(), List.of("IN-1"))); - org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "原运费待财务作废")) - .when(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1")); - assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-NEW", transferRequest("new-trip", null))); - verify(outboundOrders, never()).insert((OutboundOrder) any()); - verify(feeSources, never()).publishApproved(any(), any(), any()); - } - @Test void confirmedOriginalFeeCoverageSavesEvidenceWithoutPublishingAFabricatedNewFee() throws Exception { - var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); - var input = json.valueToTree(externalRequest(null, List.of())); - ((com.fasterxml.jackson.databind.node.ObjectNode) input).set("feeCoverage", json.valueToTree(List.of( - new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE"))))); - ((com.fasterxml.jackson.databind.node.ObjectNode) input).put("feeCoverageCargoHash", "cargo-hash"); - var request = json.treeToValue(input, DeliveryRequests.Transfer.class); - var evidence = new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence("cargo-hash", List.of(), List.of(), "OP-1", java.time.Instant.parse("2026-10-03T00:00:00Z")); - when(feeCoverage.confirm(org.mockito.ArgumentMatchers.eq("DSP-101"), org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.eq("OP-1"), any())) - .thenReturn(new DeliveryFeeCoverageService.Decision(Set.of("DELIVERY_FEE"), evidence, Set.of())); - when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A")); - when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "IN-A", 12))); - service.platformTransfer("DSP-101", "OP-1", request); - var outbound = ArgumentCaptor.forClass(OutboundOrder.class); verify(outboundOrders).insert(outbound.capture()); - assertEquals("cargo-hash", json.readTree(outbound.getValue().getFeeCoverageJson()).get("cargoHash").asText()); - org.mockito.Mockito.verifyNoInteractions(feeSources); - var delivery = ArgumentCaptor.forClass(DeliveryOrder.class); verify(deliveryOrders).insert(delivery.capture()); - when(outboundOrders.findByIdempotency("TENANT-A", request.idempotencyKey())).thenReturn(outbound.getValue()); - when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(List.of(delivery.getValue())); - assertTrue(service.platformTransfer("DSP-101", "OP-1", request).replayed()); - verify(feeCoverage, org.mockito.Mockito.times(1)).confirm(any(), any(), any(), any()); - } - @Test void emptyCoverageRepresentationsDoNotCreateFalseIdempotencyConflicts() { - var original = transferRequest("key", null); - var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); - com.fasterxml.jackson.databind.node.ObjectNode body = json.valueToTree(original); - body.putArray("feeCoverage"); body.put("feeCoverageCargoHash", "unused-hash"); - var empty = json.convertValue(body, DeliveryRequests.Transfer.class); - assertEquals(service.transferRequestHash(original), service.transferRequestHash(empty)); - } - @Test void merchantCannotDeclareOriginalFeeCoverageForItself() { - var request = new com.fasterxml.jackson.databind.ObjectMapper().convertValue(Map.of("feeCoverage", List.of( - new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE")))), DeliveryRequests.Transfer.class); - var error = assertThrows(ResponseStatusException.class, () -> service.merchantTransfer("TENANT-A", "DSP-101", "OP-1", request)); - assertEquals(403, error.getStatusCode().value()); org.mockito.Mockito.verifyNoInteractions(dispatchOrders, feeCoverage, feeSources); - } - private static DeliveryRequests.Transfer externalRequest(String hash, List fees) { - return new DeliveryRequests.Transfer("external-1", LocalDateTime.of(2026,10,3,12,0), "TRUCK_PALLET", null, null, - "WAREHOUSE_PICKUP", "PALLET", null, null, null, null, null, null, null, null, fees, hash, null, null); - } - 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 DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) { - return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null, - "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark, - List.of(receivableDeliveryFee()), null, null, null); - } - private static DeliveryRequests.FeeItem receivableDeliveryFee() { - return new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY", - new BigDecimal("10.00"), null, null, null, null, null, null, null, null, - null, null, null, null, null, null); - } + + + private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode, String destinationName, String inboundCode, int packages) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index d6a5b1436..b26dead8f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -8,6 +8,31 @@ import com.baiyue.lastleg.returnreceipt.mapper.*; import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.databind.ObjectMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository; +import com.baiyue.lastleg.dispatch.port.DispatchExecutionPort; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -104,40 +129,215 @@ class ReturnOutboundClosureTest { verify(stock, never()).shipCarton(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong()); } - @Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() { - OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1"); - outbound.setOutboundNo("OUT1"); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); - when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound); - when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound); - var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication(); - application.setId(50L); application.setCustomerId("C1"); application.setRelabelNo("REL1"); - application.setOutboundOrderNo("OUT1"); application.setStatus("READY_FOR_OUTBOUND"); - when(relabelApplications.lockByOutbound("C1", "OUT1")).thenReturn(application); - OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC#1-1"); item.setStatus("WAIT_PICK"); - item.setPickedPackageCount(0); when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item)); - carton.setStatus("OUTBOUND_PENDING"); carton.setRelabelApplicationId(50L); - service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel")); - assertThat(carton.getStatus()).isEqualTo("RELABEL_PENDING"); - assertThat(outbound.getStatus()).isEqualTo("CANCELLED"); - verify(relabelService).changeStatus(eq("C1"), eq("REL1"), eq("OP1"), - argThat(request -> "CANCELLED".equals(request.status()))); - var sequence = inOrder(relabelApplications, outbounds, cartons, relabelService); - sequence.verify(relabelApplications).lockByOutbound("C1", "OUT1"); - sequence.verify(outbounds).lockGlobalByNo("OUT1"); - sequence.verify(cartons).lockMark("RC#1-1"); - sequence.verify(relabelService).changeStatus(anyString(), anyString(), anyString(), any()); + /** 实际取消只撤销安排,已确认申请、换标照片/逐件事实及原库存保持不变。 */ + @Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() throws Exception { + var f = new CancellationFixture(); var proof = f.proof(); + f.cancel(); + assertThat(f.text("ll_return_inventory_carton", "status", 10)).isEqualTo("RELABEL_PENDING"); + assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_outbound_pallet_item", "status", 40)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_outbound_pda_task", "status", 20)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_delivery_order", "status", 60)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_relabel_application", "status", 50)).isEqualTo("READY_FOR_OUTBOUND"); + assertThat(f.text("ll_relabel_application", "fulfillment_status", 50)).isEqualTo("WAIT_OUTBOUND_CREATION"); + assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull(); + assertThat(f.text("ll_order", "outbound_order_no", 70)).isNull(); + assertThat(f.text("ll_order", "delivery_order_no", 70)).isNull(); + assertThat(f.text("ll_order", "current_status", 70)).isEqualTo("WAIT_OUTBOUND"); + assertThat(f.proof()).isEqualTo(proof); + assertThat(f.locks.subList(0,3)).containsExactly("relabel", "outbound", "carton"); + verifyNoInteractions(f.relabelLifecycle, f.execution); + var after = f.state(); f.cancel(); assertThat(f.state()).isEqualTo(after); + assertThatThrownBy(() -> f.service.pdaPick("1", "picker", "TASK1", + new DeliveryRequests.PdaPick(40L,null,1L,"old-outbound-pick",List.of("RC#1-1")))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("not available for picking"); + assertThat(f.proof()).isEqualTo(proof); } - @Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() { - OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1"); - outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); - when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound); - when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound); - OutboundPalletItem item = new OutboundPalletItem(); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(1); - when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item)); - assertThatThrownBy(() -> service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel"))) + @Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() throws Exception { + var f = new CancellationFixture(); + f.db.update("UPDATE ll_outbound_pallet_item SET picked_package_count=1 WHERE id=40"); + f.insert("ll_outbound_pda_pick", "id", 90, "customer_id", "C1", "task_id", 20, + "outbound_pallet_item_id", 40, "packages", 1, "idempotency_key", "actual-pick"); + var original = f.state(); + assertThatThrownBy(f::cancel) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("execution has started"); - verifyNoInteractions(relabelService); - verify(cartons, never()).updateById(any(ReturnInventoryCarton.class)); + assertThat(f.state()).isEqualTo(original); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Long.class)).isEqualTo(1); + verifyNoInteractions(f.relabelLifecycle, f.execution, f.fees); + } + + /** 普通仓托换标同样只取消出库安排,不释放真实原入仓位置的HOLD或重做换标。 */ + @Test void cancelsUntouchedWarehousePalletRelabelWithoutReleasingItsHold() throws Exception { + var f = new CancellationFixture(); f.warehousePalletSource(); var proof = f.proof(); + f.cancel(); + assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_outbound_pallet_item", "status", 40)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_relabel_application", "status", 50)).isEqualTo("READY_FOR_OUTBOUND"); + assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull(); + assertThat(f.text("ll_relabel_application", "fulfillment_status", 50)).isEqualTo("WAIT_OUTBOUND_CREATION"); + assertThat(f.db.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)).isEqualTo(1); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + assertThat(f.proof()).isEqualTo(proof); + assertThat(f.text("ll_order", "outbound_order_no", 70)).isNull(); + assertThat(f.text("ll_order", "delivery_order_no", 70)).isNull(); + verifyNoInteractions(f.relabelLifecycle,f.execution); + var cancelled = f.state(); f.cancel(); assertThat(f.state()).isEqualTo(cancelled); + } + + /** 原出库号不匹配不能通过申请ID或其他客户的同类记录恢复关联。 */ + @Test void cancellationRejectsChangedRelabelAssociationWithoutAnyWrite() throws Exception { + for (String column : List.of("outbound_order_no", "customer_id", "overseas_warehouse_id")) { + var f = new CancellationFixture(); + f.db.update("UPDATE ll_relabel_application SET " + column + "=? WHERE id=50", "other"); + var original = f.state(); + assertThatThrownBy(f::cancel).isInstanceOf(ResponseStatusException.class).hasMessageContaining("关联状态已变化"); + assertThat(f.state()).isEqualTo(original); + } + } + + /** 相同箱号也必须属于原出库的客户和仓,不能解除另一个客户的库存预留。 */ + @Test void cancellationRejectsForeignCustomerOrWarehouseCarton() throws Exception { + for (String column : List.of("customer_id", "overseas_warehouse_id")) { + var f = new CancellationFixture(); + f.db.update("UPDATE ll_return_inventory_carton SET " + column + "=? WHERE id=10", "other"); + var original = f.state(); + assertThatThrownBy(f::cancel).isInstanceOf(ResponseStatusException.class).hasMessageContaining("预留状态异常"); + assertThat(f.state()).isEqualTo(original); + } + } + + /** 在申请和箱预留已实际恢复后注入下游失败,所有关联、任务及派送状态必须整体回滚。 */ + @Test void laterCancellationFailureRollsBackTheWholeArrangementAndAllowsRetry() throws Exception { + var f = new CancellationFixture(); var original = f.state(); + when(f.fees.listForBusiness("C1", "DELIVERY_ORDER", 60L)).thenAnswer(call -> { + assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull(); + assertThat(f.text("ll_return_inventory_carton", "status", 10)).isEqualTo("RELABEL_PENDING"); + throw new IllegalStateException("取消后续核对失败"); + }); + assertThatThrownBy(f::cancel).isInstanceOf(IllegalStateException.class).hasMessage("取消后续核对失败"); + assertThat(f.state()).isEqualTo(original); + doReturn(List.of()).when(f.fees).listForBusiness("C1", "DELIVERY_ORDER", 60L); + f.cancel(); + assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull(); + } + + /** 真实数据库装配取消仓储与全部Mapper;仅隔离费用查询等外部端口,不伪造取消成功。 */ + private static final class CancellationFixture { + final JdbcTemplate db; + final DeliveryTransferService service; + final OverseasFeeSourceService fees = mock(OverseasFeeSourceService.class); + final DispatchExecutionPort execution = mock(DispatchExecutionPort.class); + final com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelLifecycle = mock(com.baiyue.lastleg.relabel.service.RelabelApplicationService.class); + final List locks = new ArrayList<>(); + + /** 按统一初始化实际建表,保留列、默认值及约束,不以Java字段猜测正式结构。 */ + CancellationFixture() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:cancel-native-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + db = new JdbcTemplate(ds); + String schema = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for (String table : List.of("ll_outbound_order", "ll_outbound_pallet_item", "ll_outbound_pda_task", "ll_outbound_pda_pick", + "ll_delivery_order", "ll_delivery_operation", "ll_relabel_application", "ll_return_inventory_carton", + "ll_return_inventory_carton_item", "ll_warehouse_transfer_order", "ll_order", "ll_order_status_history", + "ll_relabel_item", "ll_relabel_pda_task", "ll_relabel_pda_unit_fact", "ll_relabel_pda_history", + "ll_inventory_balance", "ll_inventory_position", "ll_inventory_ledger")) { + int start = schema.indexOf("CREATE TABLE " + table + " ("); int end = schema.indexOf(") ENGINE=", start); + if (start < 0 || end < 0) throw new IllegalStateException("统一初始化缺少取消测试表:" + table); + db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b", "CLOB") + .replaceAll("(?i)\\s+CHARACTER SET\\s+\\w+", "").replaceAll("(?i)\\s+COLLATE\\s+\\w+", "") + .replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)", "$1 " + table + "_$2")); + } + var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + for (var type : List.of(OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaTaskMapper.class,OutboundPdaPickMapper.class, + DeliveryOrderMapper.class,DeliveryOperationMapper.class,RelabelApplicationMapper.class,ReturnInventoryCartonMapper.class, + WarehouseTransferOrderMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class)) config.addMapper(type); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); + var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); + var outboundMapper = traced(sessions.getMapper(OutboundOrderMapper.class), OutboundOrderMapper.class, "lockGlobalByNo", "outbound"); + var relabelMapper = traced(sessions.getMapper(RelabelApplicationMapper.class), RelabelApplicationMapper.class, "lockByOutbound", "relabel"); + var cartonMapper = traced(sessions.getMapper(ReturnInventoryCartonMapper.class), ReturnInventoryCartonMapper.class, "lockMark", "carton"); + var target = new DeliveryTransferService(); + ReflectionTestUtils.setField(target,"cancellations",new DeliveryCancellationRepository(outboundMapper,sessions.getMapper(OutboundPalletItemMapper.class), + sessions.getMapper(DeliveryOrderMapper.class),sessions.getMapper(OutboundPdaTaskMapper.class),sessions.getMapper(DeliveryOperationMapper.class),relabelMapper, + cartonMapper,sessions.getMapper(WarehouseTransferOrderMapper.class),sessions.getMapper(LastlegOrderMapper.class),sessions.getMapper(LastlegOrderStatusHistoryMapper.class))); + ReflectionTestUtils.setField(target,"outboundOrders",outboundMapper); ReflectionTestUtils.setField(target,"outboundPallets",sessions.getMapper(OutboundPalletItemMapper.class)); + ReflectionTestUtils.setField(target,"pdaTasks",sessions.getMapper(OutboundPdaTaskMapper.class)); ReflectionTestUtils.setField(target,"pdaPicks",sessions.getMapper(OutboundPdaPickMapper.class)); + ReflectionTestUtils.setField(target,"feeSources",fees); ReflectionTestUtils.setField(target,"execution",execution); + ReflectionTestUtils.setField(target,"relabelService",relabelLifecycle); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource())); + service = (DeliveryTransferService)proxy.getProxy(); + when(fees.listForBusiness(anyString(),anyString(),anyLong())).thenReturn(List.of()); + seed(); + } + + /** 记录真实Mapper的取锁次序,调用及SQL均委托原MyBatis实现。 */ + private T traced(T actual,Class type,String method,String label) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(),new Class[]{type},(proxy,called,args) -> { + if (called.getName().equals(method)) locks.add(label); + try { return called.invoke(actual,args); } + catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); } + })); + } + + /** 已确认原生换标、未扫码独立出库及其真实派送/来源关联。 */ + private void seed() { + var at = LocalDateTime.of(2026,10,19,12,0); + insert("ll_outbound_order","id",30,"customer_id","C1","outbound_no","OUT1","overseas_warehouse_id","1","outbound_type","RELABEL_OUTBOUND","status","WAIT_PICK","idempotency_key","OUT1-CREATE"); + insert("ll_outbound_pallet_item","id",40,"customer_id","C1","outbound_order_id",30,"carton_marks","RC#1-1","package_count",1,"status","WAIT_PICK"); + insert("ll_outbound_pda_task","id",20,"customer_id","C1","outbound_order_id",30,"task_no","TASK1","status","PENDING"); + insert("ll_delivery_order","id",60,"customer_id","C1","delivery_no","DEL1","outbound_order_id",30,"outbound_no","OUT1","delivery_group_key","NATIVE","status","WAIT_DELIVERY","idempotency_key","DEL1-CREATE"); + insert("ll_relabel_application","id",50,"customer_id","C1","relabel_no","REL1","lastleg_order_id",70,"customer_order_no","CUSTOMER-1","overseas_warehouse_id","1","label_deadline",at.plusDays(1),"contact_name","测试","contact_phone","10000000000","delivery_method","EXPRESS","outbound_order_no","OUT1","status","READY_FOR_OUTBOUND","fulfillment_status","OUTBOUND_CREATED","customer_confirmed_at",at,"customer_confirmation_key","CONFIRMED-1","wallet_status","SETTLED","idempotency_key","REL1-CREATE","request_hash","a".repeat(64)); + insert("ll_return_inventory_carton","id",10,"customer_id","C1","overseas_warehouse_id","1","carton_mark","RC#1-1","status","OUTBOUND_PENDING","relabel_application_id",50,"relabel_no","REL1","relabel_status","COMPLETED","relabel_completed_at",at,"sku_types",1,"total_quantity",6,"created_at",at,"updated_at",at); + insert("ll_return_inventory_carton_item","id",11,"customer_id","C1","carton_id",10,"sku_code","NEW-SKU","quantity",6); + insert("ll_order","id",70,"customer_id","C1","order_no","RELABEL-ORDER","order_type","RELABEL_APPLICATION","source_region","OVERSEAS","source_inbound_id","REL1","source_event_id","REL1","source_loading_plan_id","REL1","source_payload_hash","a".repeat(64),"relabel_order_no","REL1","relabel_status","COMPLETED","current_status","WAIT_OUTBOUND","outbound_order_no","OUT1","delivery_order_no","DEL1"); + insert("ll_relabel_item","id",71,"application_id",50,"customer_id","C1","product_name_zh","测试","total_package_count",1,"notified_package_count",1,"return_carton_id",10,"operation_type","CARTON_LABEL","carton_mark","NEW-BOX"); + insert("ll_relabel_pda_task","id",80,"application_id",50,"customer_id","C1","relabel_no","REL1","overseas_warehouse_id",1,"status","COMPLETED","operator_key","picker","operator_name","测试","expected_packages",1,"relabelled_packages",1,"claimed_at",at,"completion_photo_file_ids","[501]","reviewed_at",at); + insert("ll_relabel_pda_unit_fact","id",81,"task_id",80,"application_id",50,"item_id",71,"customer_id","C1","old_label","RC#1-1","new_label","NEW-BOX","result","RELABELLED","quantity",1,"operator_key","picker","operator_name","测试","occurred_at",at); + insert("ll_relabel_pda_history","id",82,"task_id",80,"application_id",50,"customer_id","C1","action","COMPLETE","to_status","WAIT_REVIEW","operator_key","picker","operator_name","测试","detail_json","{\"roundKey\":\"INITIAL\",\"photoFileIds\":[501]}"); + } + + /** 使用真实原入仓余额和仓位表示普通已换标仓托;原生退货箱记录不参与此场景。 */ + void warehousePalletSource() { + db.update("DELETE FROM ll_return_inventory_carton_item"); db.update("DELETE FROM ll_return_inventory_carton"); + db.update("UPDATE ll_outbound_pallet_item SET warehouse_pallet_id=501,pallet_no='PL-501',carton_marks='BOX-ORD' WHERE id=40"); + db.update("UPDATE ll_relabel_item SET return_carton_id=NULL,source_inventory_position_id=101,source_inventory_balance_id=601,source_inbound_order_id=701,source_inbound_order_no='IN-701',source_inbound_code='IN-701' WHERE id=71"); + db.update("UPDATE ll_relabel_pda_unit_fact SET old_label='BOX-ORD' WHERE id=81"); + insert("ll_order","id",701,"customer_id","C1","order_no","ORIGINAL-INBOUND","source_region","OVERSEAS","source_inbound_id","IN-701","source_event_id","IN-701","source_loading_plan_id","IN-701","source_payload_hash","b".repeat(64),"current_status","INBOUND_COMPLETED"); + insert("ll_inventory_balance","id",601,"customer_id","C1","customer_name","测试客户","overseas_warehouse_id","1","inbound_order_id",701,"inbound_code","IN-701","hold_packages",1); + insert("ll_inventory_position","id",101,"customer_id","C1","balance_id",601,"overseas_warehouse_id","1","pallet_id",501,"pallet_no","PL-501","warehouse_location_code","LOC-1","hold_packages",1); + } + + /** 每次取消经同一个实际Spring事务入口。 */ + void cancel() { service.cancelOutbound("OUT1","OP1",new DeliveryRequests.Cancel("cancel")); } + /** 合成数据只用于测试准备,生产取消全部通过真实仓储和Mapper。 */ + void insert(String table,Object... fields) { + var columns = new ArrayList(); var values = new ArrayList(); + for(int i=0;i>> proof() { + return List.of(db.queryForList("SELECT status,delivery_method,customer_confirmed_at,customer_confirmation_key,wallet_status FROM ll_relabel_application"), + db.queryForList("SELECT relabel_application_id,relabel_no,relabel_status,relabel_completed_at,total_quantity,sku_types FROM ll_return_inventory_carton"), + db.queryForList("SELECT * FROM ll_return_inventory_carton_item"), + db.queryForList("SELECT status,reviewed_at,CAST(completion_photo_file_ids AS VARCHAR) photos FROM ll_relabel_pda_task"), + db.queryForList("SELECT * FROM ll_relabel_pda_unit_fact"), + db.queryForList("SELECT id,action,CAST(detail_json AS VARCHAR) detail FROM ll_relabel_pda_history"), + db.queryForList("SELECT * FROM ll_inventory_balance"),db.queryForList("SELECT * FROM ll_inventory_position"), + db.queryForList("SELECT * FROM ll_inventory_ledger")); + } + /** 全部可变安排记录在失败和重放时必须与原提交状态一致。 */ + List>> state() { + return List.of(db.queryForList("SELECT status,outbound_order_no,fulfillment_status,updated_at FROM ll_relabel_application"), + db.queryForList("SELECT status,updated_at FROM ll_return_inventory_carton"), + db.queryForList("SELECT status,updated_at FROM ll_outbound_order"),db.queryForList("SELECT status FROM ll_outbound_pallet_item"), + db.queryForList("SELECT status,updated_at FROM ll_outbound_pda_task"),db.queryForList("SELECT status,version,updated_at FROM ll_delivery_order"), + db.queryForList("SELECT current_status,outbound_order_no,delivery_order_no,version FROM ll_order"),db.queryForList("SELECT * FROM ll_delivery_operation"), + List.of(Map.of("proof",proof()))); + } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index af62cb734..f4b40f5d8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -226,10 +226,7 @@ class DispatchCandidateMapperContractTest { @Test void relabelCandidatesReadWeightFromWarehouseTransferPalletSnapshot() throws Exception { - String source = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java")); - int start = source.indexOf("List relabelAvailable"); - int end = source.indexOf("List nativeReturnRelabelAvailable"); - String sql = source.substring(source.lastIndexOf("@Select", start), end); + String sql=String.join(" ",DispatchCandidateMapper.class.getMethod("relabelAvailable",String.class,String.class,String.class).getAnnotation(Select.class).value()); assertTrue(sql.contains("MAX(wtp.gross_weight_kg) AS grossWeightKg")); assertTrue(sql.contains("MAX(wtp.volume_cbm) AS volumeCbm")); assertTrue(sql.contains("wtp.warehouse_pallet_id=pos.pallet_id")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java index eb21f2262..a63efb2d8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java @@ -16,7 +16,7 @@ class DispatchPhysicalWeightTest { jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))"); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)"); - int tested = 0; + var tested = new java.util.HashSet(); for (var method : DispatchCandidateMapper.class.getDeclaredMethods()) { var annotation = method.getAnnotation(Select.class); if (annotation == null) continue; @@ -43,8 +43,8 @@ class DispatchPhysicalWeightTest { jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)"); assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40"); jdbc.update("DELETE FROM ll_order_detail"); - tested++; + tested.add(method.getName()); } - assertThat(tested).isEqualTo(2); + assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected"); } } 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 index d2c064b77..90fd2d6ff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java @@ -4,8 +4,12 @@ import org.junit.jupiter.api.Test; import java.nio.file.Files; import java.nio.file.Path; +import java.util.*; +import java.util.regex.Pattern; +import java.lang.reflect.Modifier; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; class DispatchSchemaContractTest { @Test @@ -17,7 +21,8 @@ class DispatchSchemaContractTest { 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("CREATE TABLE IF NOT EXISTS ll_dispatch_command")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_history")); assertTrue(sql.contains("active_key")); assertTrue(sql.contains("draft_token")); assertTrue(sql.contains("expires_at")); @@ -25,6 +30,30 @@ class DispatchSchemaContractTest { assertTrue(sql.contains("order_refs")); } + /** 正式初始化SQL和实际持久化模型必须同时去客户头,不能靠定制H2表掩盖。 */ + @Test void currentBaselineAndEntitiesHaveOneWholeTruckTaskAndResourceBinding() throws Exception { + String sql=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + var tables=Map.of("ll_dispatch_order",com.baiyue.lastleg.dispatch.entity.DispatchAggregate.class, + "ll_fleet_trip_dispatch",com.baiyue.lastleg.fleet.trip.FleetTripDispatch.class, + "ll_dispatch_pda_task",com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask.class); + for(var entry:tables.entrySet()) { + String body=table(sql,entry.getKey());var columns=new TreeSet(); + var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body); + while(matcher.find())columns.add(matcher.group(1)); + var fields=new TreeSet();for(var field:entry.getValue().getDeclaredFields())if(!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic())fields.add(field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT)); + assertThat(columns).as(entry.getKey()).isEqualTo(fields).doesNotContain("customer_id"); + } + assertThat(table(sql,"ll_fleet_trip_dispatch")).doesNotContain("review_json").containsPattern("UNIQUE KEY\\s+\\w+\\s*\\(trip_id\\)"); + assertThat(table(sql,"ll_dispatch_pda_task")).containsPattern("UNIQUE KEY\\s+\\w+\\s*\\(dispatch_order_id\\)"); + assertThat(table(sql,"ll_dispatch_order")).containsPattern("UNIQUE KEY\\s+\\w+\\s*\\(trip_id\\)"); + assertThat(table(sql,"ll_dispatch_pda_pick")).contains("customer_id"); + assertThat(table(sql,"ll_dispatch_pda_pick_order")).contains("customer_id"); + } + private static String table(String sql,String name) { + var matcher=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? "+name+"\\s*\\((.*?)\\) ENGINE=",Pattern.DOTALL).matcher(sql); + assertThat(matcher.find()).as(name).isTrue();return matcher.group(1); + } + @Test void dispatchCandidatesExcludePalletsReservedByWarehouseTransfers() throws Exception { String mapper = Files.readString(Path.of( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index ff51fffb3..33680f23e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -1,57 +1,49 @@ package com.baiyue.lastleg.dispatch; import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import java.util.*; +import static org.assertj.core.api.Assertions.*; -import java.nio.charset.StandardCharsets; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - +/** 精确解析公开草稿合同,不以全文件字符串误匹配旧客户拆单协议。 */ class SmartDispatchOpenApiContractTest { - @Test - void contractDocumentsOnlyCandidateAndPreArrangeSmartDispatchWrites() throws Exception { - String yaml; - try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { - yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + /** 草稿端点、版本化请求和候选参数必须与冻结端口一致,旧standalone路由移除。 */ + @Test void contractPublishesOwnedVersionedDraftProtocol()throws Exception { + Map root; + try(var input=getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")){root=new Yaml().load(input);} + var paths=map(root.get("paths"));String base="/api/platform/v1/last-leg/dispatches"; + assertThat(paths).doesNotContainKey(base+"/pre-arrange"); + var expected=new LinkedHashMap(); + expected.put("/drafts","DispatchCreateDraft");expected.put("/drafts/containers","DispatchCreateContainer"); + expected.put("/drafts/snapshot","DispatchDraftSnapshot");expected.put("/draft-locks/lock","DispatchDraftSelection"); + expected.put("/draft-locks/unlock","DispatchDraftSelection");expected.put("/draft-locks/release","DispatchDraftRelease"); + expected.put("/drafts/pre-arrange","DispatchConvertDraft");expected.put("/drafts/{draftToken}/containers/{containerId}","DispatchReasonCommand"); + var businessRequired=Map.of( + "DispatchCreateDraft",List.of("warehouseId"),"DispatchCreateContainer",List.of("draftToken","capacity"), + "DispatchDraftSelection",List.of("draftToken","draftContainerId","source"),"DispatchDraftSnapshot",List.of("draftToken","planIds","containers"), + "DispatchDraftRelease",List.of("draftToken"),"DispatchConvertDraft",List.of("draftToken","groups")); + var schemas=map(map(root.get("components")).get("schemas")); + for(var item:expected.entrySet()){ + String method=item.getKey().contains("{containerId}")?"delete":"post"; + var operation=map(map(paths.get(base+item.getKey())).get(method)); + var body=map(operation.get("requestBody"));assertThat(body.get("required")).isEqualTo(true); + var schema=map(map(map(body.get("content")).get("application/json")).get("schema")); + assertThat(schema.get("$ref")).isEqualTo("#/components/schemas/"+item.getValue()); + var request=map(schemas.get(item.getValue()));var fields=map(request.get("properties")); + assertThat(fields).doesNotContainKeys("customerId","planPalletIds","returnReceiptIds","expectedVersion"); + assertThat(((List)request.get("required")).contains("idempotencyKey")).isTrue(); + if(!item.getKey().equals("/drafts"))assertThat(((List)request.get("required")).contains("recordVersion")).isTrue(); + assertThat(((List)request.get("required")).containsAll(businessRequired.getOrDefault(item.getValue(),List.of()))).isTrue(); } - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/pre-arrange:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/pre-arrange:")); - assertTrue(yaml.contains("required: [draftToken, groups]")); - assertTrue(yaml.contains("required: [draftContainerId, planPalletIds, returnReceiptIds]")); - assertTrue(yaml.contains("SmartDispatchDraftGroupResult:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/lock:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/unlock:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/release:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/snapshot:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/containers:")); - assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/{draftToken}/containers/{containerId}:")); - assertTrue(yaml.contains("draftContainerId:")); - assertTrue(yaml.contains("SmartDispatchDraftContainer:")); - assertTrue(yaml.contains("capacity: { type: integer, enum: [15, 30] }")); - assertTrue(yaml.contains("SmartDispatchDraft:")); - assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:list")); - assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch")); - assertTrue(yaml.contains("SmartDispatchPreArrange")); - assertTrue(yaml.contains("SmartDispatchCandidate")); - assertTrue(yaml.contains("overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码")); - assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重")); - assertTrue(yaml.contains("禁止使用体积重替代")); - String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"), - yaml.indexOf(" SmartDispatchDraftPallet:")); - String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"), - yaml.indexOf(" DeliveryTransfer:")); - assertFalse(preArrangeSchema.contains("route:")); - assertFalse(updateSchema.contains("route:")); - assertTrue(updateSchema.contains("departAt: { type: string, format: date-time, nullable: true }")); - assertTrue(yaml.contains("name: planIds")); - String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"), - yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds"))); - assertTrue(planIdsParameter.contains("required: false")); - String dispatchContract = yaml.substring(yaml.indexOf("/api/platform/v1/last-leg/dispatches:"), - yaml.indexOf("/api/platform/v1/last-leg/outbound-orders:")); - assertFalse(dispatchContract.contains("name: customerId")); - assertFalse(yaml.contains("/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:")); + assertThat(map(paths.get(base+"/drafts"))).containsKey("get"); + var candidate=map(map(paths.get(base+"/candidates")).get("get")); + var parameterNames=((List)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList(); + assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode"); + var draft=map(map(schemas.get("DispatchDraft")).get("properties")); + assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt"); + var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch"); } + /** 失败显示实际缺失节点,避免静默按空合同放过接口漂移。 */ + @SuppressWarnings("unchecked") + private static Map map(Object value){assertThat(value).isInstanceOf(Map.class);return (Map)value;} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java index 692fa2784..a192dc45f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -2,8 +2,10 @@ package com.baiyue.lastleg.dispatch.controller; 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.service.DispatchService; +import com.baiyue.lastleg.dispatch.port.DispatchViews; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService; import com.baiyue.lastleg.security.OverseasPermissions; import org.junit.jupiter.api.BeforeEach; @@ -25,25 +27,26 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DispatchQueryControllerTest { - @Mock DispatchService service; + @Mock DispatchAggregateService service; @Mock DispatchBolPrintService printService; private DispatchQueryController controller; @BeforeEach void setUp() { controller = new DispatchQueryController(); - ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "aggregateService", service); ReflectionTestUtils.setField(controller, "printService", printService); } @Test void platformPageQueriesAcrossCustomersWithoutCustomerId() { - DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null, null); - when(service.platformPage(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + DispatchViews.Query query = new DispatchViews.Query(1,20,null,null,null,null,null,DispatchTypes.StatusFilter.ACTIVE,null,null,null); + var actor=new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PLATFORM,null); + when(service.page(actor,query)).thenReturn(new DispatchViews.Page(List.of(),0,1,20,0,null,java.time.Instant.EPOCH)); var response = controller.page(platform(), query); - verify(service).platformPage(query); + verify(service).page(actor,query); assertThat(response.code()).isEqualTo("200"); assertThat(response.data().records()).isEmpty(); assertThatThrownBy(() -> controller.page(null, query)) @@ -55,7 +58,7 @@ class DispatchQueryControllerTest { @Test void listAndDetailUseDedicatedDispatchListAuthority() throws Exception { PreAuthorize page = DispatchQueryController.class - .getMethod("page", AuthenticatedUser.class, DispatchRequests.Query.class) + .getMethod("page", AuthenticatedUser.class, DispatchViews.Query.class) .getAnnotation(PreAuthorize.class); PreAuthorize detail = DispatchQueryController.class .getMethod("detail", AuthenticatedUser.class, String.class) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index 3aeca9ec9..be757d18e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -1,96 +1,103 @@ package com.baiyue.lastleg.dispatch.controller; +import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.security.OverseasPermissions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.*; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import java.util.List; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) class SmartDispatchControllerTest { - @Mock DispatchService service; + private final DispatchPlanningPort planning=mock(DispatchPlanningPort.class); + private final DispatchAggregateService aggregate=mock(DispatchAggregateService.class); private SmartDispatchController controller; + private final DispatchContracts.Actor actor=new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PLATFORM,null); - @BeforeEach - void setUp() { - controller = new SmartDispatchController(); - ReflectionTestUtils.setField(controller, "service", service); + /** 使用真实Controller和认证主体工厂,隔离业务端口。 */ + @BeforeEach void setup(){controller=new SmartDispatchController();ReflectionTestUtils.setField(controller,"planning",planning);ReflectionTestUtils.setField(controller,"aggregate",aggregate);} + /** 清理认证上下文,避免HTTP测试泄漏给其他用例。 */ + @AfterEach void clear(){SecurityContextHolder.clearContext();} + + /** 候选仍委托实际主流程,保留仓库及全部真实筛选。 */ + @Test void candidatesKeepWarehouseMultiCustomerFilters(){ + controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1"); + verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1")); + verifyNoInteractions(planning); } - - @Test - void candidatesResolveCustomerScopeFromSelectedPlans() { - when(service.resolveCustomerScopeForPlans(List.of(1L, 2L))).thenReturn("100"); - when(service.candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1")) - .thenReturn(List.of()); - - controller.candidates(platform(), List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1"); - - verify(service).candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1"); + /** HTTP实际参数采用destinationWarehouseCode,不能因旧名称使前端筛选静默丢失。 */ + @Test void candidateHttpQueryBindsFrozenDestinationParameter()throws Exception{ + var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build(); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of())); + mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseId","92").param("destinationWarehouseCode","LAX9")) + .andExpect(status().isOk()); + verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null));verifyNoInteractions(planning); } - - @Test - void candidatesWithoutSelectedPlansUsePlatformScope() { - when(service.platformCandidates(null, null, "RECEIVED", "draft-2")).thenReturn(List.of()); - - controller.candidates(platform(), null, null, null, "RECEIVED", "draft-2"); - - verify(service).platformCandidates(null, null, "RECEIVED", "draft-2"); + /** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */ + @Test void allDraftRoutesDelegateFrozenVersionedRequests(){ + var create=new CreateDraft("create","92");var container=new CreateContainer(0L,"container","d",15); + var selection=new DraftSelection(1L,"select","d",1L,new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,null,null,2L,"v")); + var snapshot=new DraftSnapshot(2L,"snapshot","d",List.of(3L),List.of()); + var release=new DraftRelease(3L,"release","d");var convert=new ConvertDraft(4L,"convert","d",List.of()); + var reason=new DispatchCommands.ReasonCommand(5L,"remove","移除空容器"); + controller.drafts(platform());controller.createDraft(platform(),create);controller.createContainer(platform(),container); + controller.lockDraftPallet(platform(),selection);controller.unlockDraftPallet(platform(),selection);controller.saveDraft(platform(),snapshot); + controller.releaseDraft(platform(),release);controller.preArrangeDraft(platform(),convert);controller.deleteContainer(platform(),"d",1L,reason); + verify(planning).drafts(actor);verify(planning).createDraft(actor,create);verify(planning).createContainer(actor,container); + verify(planning).select(actor,selection);verify(planning).unselect(actor,selection);verify(planning).saveSnapshot(actor,snapshot); + verify(planning).release(actor,release);verify(planning).convert(actor,convert);verify(planning).removeContainer(actor,"d",1L,reason); } - - @Test - void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { - DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( - List.of(10L, 11L), "APT-1", "SUCCESS", "PENDING"); - when(service.resolveCustomerScopeForSources(List.of(10L, 11L), List.of())).thenReturn("100"); - - controller.preArrange(platform(), request); - - verify(service).preArrange("100", "7", request); + /** 草稿写权限独立于候选只读,所有操作均不接收客户参数。 */ + @Test void permissionsAndRemovedStandaloneProtocolMatchFrozenRoutes()throws Exception{ + var routes=Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .filter(method->org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation(method,RequestMapping.class)!=null).toList(); + assertThat(routes).hasSize(10); + for(var method:routes){ + var permission=method.getAnnotation(PreAuthorize.class);assertThat(permission).isNotNull(); + boolean read=Arrays.asList(org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation(method,RequestMapping.class).method()).contains(RequestMethod.GET); + String required=read?OverseasPermissions.DISPATCH_LIST:OverseasPermissions.DISPATCH_CREATE; + assertThat(permission.value()).isEqualTo("hasAuthority('"+required+"')"); + assertThat(Arrays.stream(method.getParameters()).filter(p->p.isAnnotationPresent(RequestParam.class)) + .noneMatch(p->"customerId".equals(p.getAnnotation(RequestParam.class).name()))).isTrue(); + if(method.isAnnotationPresent(PostMapping.class))assertThat(method.getAnnotation(PostMapping.class).value()).doesNotContain("/pre-arrange"); + } + var remove=SmartDispatchController.class.getMethod("deleteContainer",AuthenticatedUser.class,String.class,Long.class,DispatchCommands.ReasonCommand.class); + assertThat(remove.getAnnotation(DeleteMapping.class).value()).containsExactly("/drafts/{draftToken}/containers/{containerId}"); + assertThat(remove.getParameters()[3].isAnnotationPresent(RequestBody.class)).isTrue(); } - - @Test - void endpointsUseInboundOrderListAndDedicatedSmartDispatchAuthorities() throws Exception { - PreAuthorize candidates = SmartDispatchController.class - .getMethod("candidates", AuthenticatedUser.class, List.class, String.class, String.class, String.class, - String.class) - .getAnnotation(PreAuthorize.class); - PreAuthorize preArrange = SmartDispatchController.class - .getMethod("preArrange", AuthenticatedUser.class, DispatchRequests.PreArrange.class) - .getAnnotation(PreAuthorize.class); - - assertThat(candidates.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); - assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH); + /** 客户即使知道token也不能进入草稿端口。 */ + @Test void customerCannotReadOrCreateOrConvertDraft(){ + var merchant=new AuthenticatedUser(7L,100L,100L,8L,"customer","MERCHANT","WEB",Set.of()); + assertThatThrownBy(()->controller.drafts(merchant)).isInstanceOf(BizException.class); + assertThatThrownBy(()->controller.createDraft(merchant,new CreateDraft("key","92"))).isInstanceOf(BizException.class); + assertThatThrownBy(()->controller.preArrangeDraft(merchant,new ConvertDraft(0L,"key","d",List.of()))).isInstanceOf(BizException.class); + verifyNoInteractions(planning,aggregate); } - - @Test - void groupedDraftUsesAuthenticatedOperatorAndDedicatedWriteAuthority() throws Exception { - var request = new DispatchRequests.PreArrangeDraft("draft-1", List.of( - new DispatchRequests.DraftGroup(1L, List.of(10L), List.of()), - new DispatchRequests.DraftGroup(2L, List.of(11L), List.of()))); - controller.preArrangeDraft(platform(), request); - verify(service).preArrangeDraft("7", request); - assertThat(SmartDispatchController.class.getMethod("preArrangeDraft", AuthenticatedUser.class, - DispatchRequests.PreArrangeDraft.class).getAnnotation(PreAuthorize.class).value()) - .contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH); - org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> controller.preArrangeDraft(new AuthenticatedUser(7L, 100L, 0L, 8L, "customer", "MERCHANT", "WEB", Set.of()), request)); - } - - private static AuthenticatedUser platform() { - return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", - Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)); + /** DELETE必须实际读取版本化请求体,缺版本拒绝;旧standalone写路由不再存在。 */ + @Test void deleteReadsVersionedBodyAndLegacyPreArrangeIsMissing()throws Exception{ + var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build(); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of())); + mvc.perform(delete("/api/platform/v1/last-leg/dispatches/drafts/d/containers/1").contentType("application/json") + .content("{\"recordVersion\":0,\"idempotencyKey\":\"remove\",\"reason\":\"empty\"}")).andExpect(status().isOk()); + verify(planning).removeContainer(actor,"d",1L,new DispatchCommands.ReasonCommand(0L,"remove","empty")); + clearInvocations(planning); + mvc.perform(delete("/api/platform/v1/last-leg/dispatches/drafts/d/containers/1").contentType("application/json") + .content("{\"idempotencyKey\":\"missing\",\"reason\":\"empty\"}")).andExpect(status().isBadRequest()); + mvc.perform(post("/api/platform/v1/last-leg/dispatches/pre-arrange").contentType("application/json").content("{}")).andExpect(status().isNotFound()); + verifyNoInteractions(planning); } + /** 平台作用域只取真实认证主体。 */ + private static AuthenticatedUser platform(){return new AuthenticatedUser(7L,0L,0L,8L,"operator","PLATFORM","WEB",Set.of(OverseasPermissions.DISPATCH_LIST,OverseasPermissions.DISPATCH_CREATE));} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java index 3034312e9..a9a5012fa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java @@ -1,32 +1,39 @@ package com.baiyue.lastleg.dispatch.fleet; - -import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.dispatch.service.*; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.fleet.trip.*; -import org.junit.jupiter.api.Test; +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import java.math.BigDecimal; -import java.util.List; +import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; - class FleetDispatchPlanningAdapterTest { - private final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class); - private final DispatchService service=mock(DispatchService.class); - private final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class); - private final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,service,gateway); + final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class); + final DispatchSourceRepository sources=mock(DispatchSourceRepository.class); + final DispatchService rules=mock(DispatchService.class); + final DispatchAggregateService main=mock(DispatchAggregateService.class); + final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class); + final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,sources,rules,gateway,main); + @AfterEach void clear(){SecurityContextHolder.clearContext();} private FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-1","IN-1","92","FBA:ACY2","READY_FOR_DISPATCH","hash",List.of(new FleetBookingIncomingGateway.Pallet("1","P-1",10,BigDecimal.TEN,BigDecimal.ONE)),10);} - @Test void refusesAmbiguousSourcesWithoutCreatingAnotherDispatch() { - when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1","DS-2")); - assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("多张排车"); - verifyNoInteractions(service); + @Test void keepsOtherCustomerAndOtherInboundCargoWhileVerifyingTheExactReceiptSubset(){ + when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","92",50)); + when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"),new FleetDispatchGateway.SourceCargo("OTHER-IN","2","P-2",7,"FBA:ONT8"))); + adapter.validateIncomingDispatch("DS-1","9",receipt());verify(gateway).sourceCargo("DS-1","9");verifyNoInteractions(main); + when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2"))); + assertThatThrownBy(()->adapter.validateIncomingDispatch("DS-1","9",receipt())).hasMessageContaining("未完整覆盖"); } - @Test void reusesOnlyAnExactExistingShipment() { - when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1")); - when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","9","92",10)); - when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"))); - assertThat(adapter.resolveIncoming("9",receipt(),"1")).isEqualTo("DS-1");verifyNoInteractions(service); - when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2"))); - assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("全部实收"); - when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-other","1","P-1",10,"FBA:ACY2"))); - assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("其他来源"); + @Test void joinsTheTripExistingMainAndDoesNotCreateASecondCustomerDispatch(){ + var user=new AuthenticatedUser(1L,0L,0L,1L,"operator","PLATFORM","WEB",Set.of());SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,List.of())); + when(mapper.dispatchForTrip("TR-1")).thenReturn("DS-1");var context=mock(DispatchContracts.Context.class);when(context.dispatchId()).thenReturn(1L);when(main.lockForArrangement(any(),eq("DS-1"))).thenReturn(context); + when(mapper.selectedPlans(1,"9")).thenReturn(List.of(10L));when(mapper.receivedPlanPallets("9","IN-1")).thenReturn(List.of(10L)); + when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","92",10));when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"))); + assertThat(adapter.resolveIncoming("TR-1","9",receipt(),"1","booking-original")).isEqualTo("DS-1");verify(main,never()).create(any(),any());verify(main,never()).addCargo(any(),any(),any());verifyNoInteractions(sources,rules); + assertThatThrownBy(()->adapter.resolveIncoming("TR-1","9",receipt(),"forged","booking-original")).hasMessageContaining("操作者"); } + @Test void wrongWarehouseCannotBeAcceptedByMatchingReceiptTotals(){when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","OTHER",10));assertThatThrownBy(()->adapter.validateIncomingDispatch("DS-1","9",receipt())).hasMessageContaining("起运仓");verify(gateway,never()).sourceCargo(any(),any());} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java index 47ef4aa44..14ec6e0fb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java @@ -16,7 +16,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.*; class FleetPlanningReadPersistenceTest { - @Test void candidatesPaginateOnlyEligibleSameWarehouseUnboundCargoAndSourcesPreserveUnknowns() throws Exception { + @Test void externalSourcesKeepActualCustomerAndUnknownQuantities() throws Exception { var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var jdbc=new JdbcTemplate(ds); jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(64),customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),package_count INT,status VARCHAR(32),picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP,route VARCHAR(500))"); @@ -38,9 +38,6 @@ class FleetPlanningReadPersistenceTest { var interceptor=new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));configuration.addInterceptor(interceptor); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration); var session=new SqlSessionTemplate(factory.getObject());var mapper=session.getMapper(FleetDispatchPlanningMapper.class); - var page=mapper.candidates(new Page<>(1,1),"TR-1","92",null); - assertThat(page.getTotal()).isEqualTo(2);assertThat(page.getRecords()).hasSize(1); - assertThat(mapper.candidates(new Page<>(1,100),"TR-1","92","DS-1").getRecords()).extracting(r->r.dispatchNo()).containsExactly("DS-1"); var resolver=new FleetPoolSourceResolver(session.getMapper(FleetPoolOrderMapper.class),new ObjectMapper()); var sources=resolver.resolve(List.of(new FleetPoolSourceResolver.SourceRef("9","FB-5"),new FleetPoolSourceResolver.SourceRef("9","FB-6"),new FleetPoolSourceResolver.SourceRef("OTHER","FB-6"))); assertThat(sources).hasSize(2); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java new file mode 100644 index 000000000..8cad48d09 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.dispatch.gateway; + +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.assertj.core.api.Assertions.*; + +/** 文件复合归属直接验证,不连接File或把任意文件ID当作有效证据。 */ +class HttpDispatchEvidenceGatewayTest { + /** 元数据存在但磁盘文件缺失的HTTP200错误JSON必须作为失败返回。 */ + @Test void downloadRejectsHttp200BusinessErrorInsteadOfReturningItAsPdf() { + var f=new DownloadFixture();f.respond("{\"code\":\"A0404\",\"message\":\"missing\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8),org.springframework.http.MediaType.APPLICATION_JSON); + assertThatThrownBy(()->f.gateway.download(9,DispatchEvidenceGateway.Kind.DRIVER,3)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(404));f.server.verify(); + } + /** 实际内容必须同时符合权威长度、MIME及既有文件签名要求。 */ + @Test void downloadChecksMimeLengthAndSignatureAndAcceptsRealPdf() { + var good=new DownloadFixture();good.respond(DownloadFixture.PDF,org.springframework.http.MediaType.APPLICATION_PDF); + assertThat(good.gateway.download(9,DispatchEvidenceGateway.Kind.DRIVER,3)).containsExactly(DownloadFixture.PDF);good.server.verify(); + for(int variant=0;variant<3;variant++) { + var f=new DownloadFixture();byte[] body=variant==2?java.util.Arrays.copyOf(DownloadFixture.PDF,DownloadFixture.PDF.length+1):variant==1?new byte[DownloadFixture.PDF.length]:DownloadFixture.PDF; + f.respond(body,variant==0?org.springframework.http.MediaType.IMAGE_PNG:org.springframework.http.MediaType.APPLICATION_PDF); + assertThatThrownBy(()->f.gateway.download(9,DispatchEvidenceGateway.Kind.DRIVER,3)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(503));f.server.verify(); + } + } + /** 格式业务拒绝与真正上游故障分开,不把A0400归到503。 */ + @Test void businessRejectionRetainsClientFailureStatus() { + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("A0400",null),9,DispatchEvidenceGateway.Kind.DRIVER,null)) + .isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(400)); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("S0503",null),9,DispatchEvidenceGateway.Kind.DRIVER,null)) + .isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(503)); + } + /** 平台本车本类证据才能成为可审核事实。 */ + @Test void verifiesCurrentTruckPlatformAndKind() { + var own=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_EVIDENCE","9","license.pdf","application/pdf",128L); + var result=HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.DRIVER,3L); + assertThat(result.fileId()).isEqualTo(3L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),10,DispatchEvidenceGateway.Kind.DRIVER,3L)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.VEHICLE,3L)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.DRIVER,4L)).isInstanceOf(ResponseStatusException.class); + } + /** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */ + @Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() { + for(var value:java.util.List.of( + new HttpDispatchEvidenceGateway.Metadata(3L,101L,"OVERSEAS_DISPATCH_DRIVER_EVIDENCE","9","license.pdf","application/pdf",128L), + new HttpDispatchEvidenceGateway.Metadata(3L,0L,"FLEET_DRIVER_LICENSE","9","license.pdf","application/pdf",128L), + new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_EVIDENCE","9","license.pdf",null,128L))) + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",value),9,DispatchEvidenceGateway.Kind.DRIVER,3L)).isInstanceOf(ResponseStatusException.class); + } + /** 使用Spring内存HTTP提供者,不连接File或其他运行环境。 */ + private static final class DownloadFixture { + static final byte[] PDF="%PDF-1.7\nTEST\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + final org.springframework.test.web.client.MockRestServiceServer server; + final HttpDispatchEvidenceGateway gateway; + /** 固定客户端请求工厂,只有明示的模拟响应能被读取。 */ + DownloadFixture() { + var builder=org.springframework.web.client.RestClient.builder();server=org.springframework.test.web.client.MockRestServiceServer.bindTo(builder).build(); + var factory=org.mockito.Mockito.mock(com.baiyue.lastleg.config.LastlegRestClientFactory.class); + org.mockito.Mockito.when(factory.build(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any())).thenReturn(builder.baseUrl("https://file.invalid").build()); + gateway=new HttpDispatchEvidenceGateway(builder,factory,new com.baiyue.lastleg.config.LastlegRestClientProperties(),"https://file.invalid","offline-test"); + } + /** 先返回有效本车元数据,再精确提供要验证的内容响应。 */ + void respond(byte[] body,org.springframework.http.MediaType mime) { + String url="https://file.invalid/internal/file/v1/business-files/0/OVERSEAS_DISPATCH_DRIVER_EVIDENCE/9/3"; + server.expect(org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo(url)).andRespond(org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess( + "{\"code\":\"200\",\"data\":{\"fileId\":3,\"tenantId\":0,\"businessType\":\"OVERSEAS_DISPATCH_DRIVER_EVIDENCE\",\"businessId\":\"9\",\"fileName\":\"license.pdf\",\"contentType\":\"application/pdf\",\"sizeBytes\":"+PDF.length+"}}",org.springframework.http.MediaType.APPLICATION_JSON)); + server.expect(org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo(url+"/content")).andRespond(org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess(body,mime)); + } + } +} 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 index 7f2301a0c..05632112f 100644 --- 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 @@ -1,380 +1,40 @@ package com.baiyue.lastleg.dispatch.pda.service; - -import com.baiyue.lastleg.delivery.entity.OutboundOrder; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; -import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; -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.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.DispatchPdaOperation; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; -import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; -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 com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import org.junit.jupiter.api.*; import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.HexFormat; -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.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.anyLong; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class DispatchPdaWorkflowServiceTest { - @Mock DispatchOrderMapper orders; - @Mock OutboundOrderMapper outboundOrders; - @Mock OutboundPalletItemMapper outboundPalletItems; - @Mock DispatchItemMapper items; - @Mock DispatchPdaTaskMapper tasks; - @Mock DispatchPdaPickMapper picks; - @Mock DispatchPdaOperationMapper operations; - @Mock DispatchPalletLockMapper palletLocks; - @Mock InventoryPositionMapper positions; - @Mock InventoryCommandService inventory; - @Mock DispatchPickOrderAttributionService attribution; - @Mock com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnStock; - @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; - private DispatchPdaWorkflowService service; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - service = new DispatchPdaWorkflowService(); - ReflectionTestUtils.setField(service, "deliveryExecution", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.class)); - org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); - ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); - objectMapper = new ObjectMapper(); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); - ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "tasks", tasks); - ReflectionTestUtils.setField(service, "picks", picks); - ReflectionTestUtils.setField(service, "operations", operations); - ReflectionTestUtils.setField(service, "palletLocks", palletLocks); - ReflectionTestUtils.setField(service, "positions", positions); - ReflectionTestUtils.setField(service, "inventory", inventory); - ReflectionTestUtils.setField(service, "pickAttribution", attribution); - ReflectionTestUtils.setField(service, "returnRelabelStock", returnStock); - ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + final DispatchPdaWorkflowService service=new DispatchPdaWorkflowService(); + final DispatchPickingRepository picks=mock(DispatchPickingRepository.class); + final DispatchSourceDepartureRepository source=mock(DispatchSourceDepartureRepository.class); + final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class); + final FleetPoolOrderBridge fleet=mock(FleetPoolOrderBridge.class); + final RelabelApplicationService relabels=mock(RelabelApplicationService.class); + final DispatchContracts.Actor actor=new DispatchContracts.Actor("operator",DispatchTypes.ActorMode.PLATFORM,null); + @BeforeEach void setup(){ReflectionTestUtils.setField(service,"wholePicks",picks);ReflectionTestUtils.setField(service,"wholeDeparture",source);ReflectionTestUtils.setField(service,"pickAttribution",attribution);ReflectionTestUtils.setField(service,"fleetPool",fleet);ReflectionTestUtils.setField(service,"relabelApplications",relabels);} + @Test void departureAdvancesEachRealCustomerAndUsesItsOwnImmutablePicks(){ + var context=context(List.of(cargo("102",22L,5,null),cargo("101",11L,3,null))); + var a=List.of(new DispatchPdaPick());var b=List.of(new DispatchPdaPick());when(picks.picks("101",1)).thenReturn(a);when(picks.picks("102",1)).thenReturn(b); + var at=Instant.parse("2026-10-19T10:00:00Z");service.departWholeSources(context,actor,result("101","102"),at); + var local=LocalDateTime.ofInstant(at,ZoneOffset.UTC);verify(attribution).depart("101","OUT-101","DP-ONE",a,local);verify(attribution).depart("102","OUT-102","DP-ONE",b,local); + verify(source).transferOrder("101",11L,local);verify(source).transferOrder("102",22L,local); + verify(fleet).departed("101","DP-ONE",Map.of(11L,3),"operator",local);verify(fleet).departed("102","DP-ONE",Map.of(22L,5),"operator",local); + verifyNoInteractions(relabels); } - - @Test - void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() { - assertEquals("ONT8 -> ONT", DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ONT")); - assertNull(DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ont8")); - assertNull(DispatchPdaWorkflowService.warehouseRouteLabel(null, "ONT")); + @Test void missingActualPicksCannotAdvanceAnySourceOrFleetOrder(){ + assertThatThrownBy(()->service.departWholeSources(context(List.of(cargo("101",11L,3,null))),actor,result("101"),Instant.now())).hasMessageContaining("真实扫码");verifyNoInteractions(source,attribution,fleet,relabels); } - - @Test - void taskListUsesOnlyTokenBoundWarehouseAndResolvedCustomer() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); - DispatchPdaTask task = task(11L, "100", "WH-1", "PDA_PENDING"); - when(tasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task)); - when(orders.selectById(1L)).thenReturn(order); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of()); - - List result = service.list("WH-1", null); - - assertEquals(1, result.size()); - assertEquals("DSP-1", result.get(0).dispatchNo()); - verify(tasks).listByWarehouse("WH-1", null); - } - - @Test - void detailRejectsDispatchFromAnotherWarehouseBeforeCustomerLookup() { - when(orders.findGlobalByDispatchNo("DSP-1")) - .thenReturn(order("100", "DSP-1", "WH-2", "PENDING_DEPARTURE")); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.task("WH-1", "DSP-1")); - - assertEquals(404, exception.getStatusCode().value()); - verify(tasks, never()).findByDispatch(any(), any()); - } - - @Test - void completedTaskExposesTransferDeliveryGateBeforeDeparture() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - DispatchItem item = item(21L, "100", 6); - item.setInboundCode("IN-1"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(tasks.findByDispatch("100", 1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); - - DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1"); - - assertFalse(result.canDepart()); - assertEquals("请先在平台完成“转派送”,生成待出库单后才能发车。", result.departBlockReason()); - } - - @Test - void completedTaskExposesDepartureWhenPendingOutboundExists() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - DispatchItem item = item(21L, "100", 6); - OutboundOrder outbound = new OutboundOrder(); - outbound.setStatus("WAIT_OUTBOUND"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(tasks.findByDispatch("100", 1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); - when(outboundOrders.findByDispatch("100", 1L)).thenReturn(outbound); - - DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1"); - - assertTrue(result.canDepart()); - assertNull(result.departBlockReason()); - } - - @Test - void exactPickRetryReturnsPersistedResultAfterTaskAutomaticallyCompleted() throws Exception { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - task.setOperatorId("7"); - DispatchPdaRequests.Pick request = new DispatchPdaRequests.Pick(31L, 2, 2, "pick-1", null); - DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false); - DispatchPdaOperation replay = operation("PICK", request, persisted); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(tasks.lockByDispatch("100", 1L)).thenReturn(task); - when(operations.findKey("100", "pick-1")).thenReturn(replay); - when(orders.lockById("100", 1L)).thenReturn(order); - - DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request); - - assertEquals(2, result.packages()); - verify(fleetBindings, never()).requirePickingApproved(anyLong()); - verify(inventory, never()).allocate(any(), any(), any()); - verify(picks, never()).insert((DispatchPdaPick) any()); - verify(items, never()).bindReceivedPallets(any(), any()); - var locks = org.mockito.Mockito.inOrder(orders, tasks); - locks.verify(orders).findGlobalByDispatchNo("DSP-1"); - locks.verify(orders).lockById("100", 1L); - locks.verify(tasks).lockByDispatch("100", 1L); - } - - @Test - void completionRejectsPartialPickingWithoutStatusDrift() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING"); - DispatchItem item = item(21L, "100", 6); - DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(tasks.lockByDispatch("100", 1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("done-1"))); - - assertEquals(409, exception.getStatusCode().value()); - assertEquals("PICKING", order.getStatus()); - verify(orders, never()).updateById((DispatchOrder) any()); - } - - @Test - void automaticallyCompletedTaskPersistsFirstCompleteKeyThenReplaysIt() throws Exception { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - DispatchItem item = item(21L, "100", 6); - DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L); - DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(tasks.lockByDispatch("100", 1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); - - DispatchPdaResponses.PickingCompleted first = service.completePicking("WH-1", "DSP-1", request); - assertFalse(first.replayed()); - verify(operations).insert((DispatchPdaOperation) any()); - - DispatchPdaOperation replay = operation("COMPLETE_PICKING", request, first); - when(operations.findKey("100", "done-1")).thenReturn(replay); - DispatchPdaResponses.PickingCompleted second = service.completePicking("WH-1", "DSP-1", request); - assertTrue(second.replayed()); - } - - @Test - void departRequiresFullyCompletedPickingAndPendingOutbound() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "PARTIAL_READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - OutboundOrder outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now())); - - assertEquals(409, exception.getStatusCode().value()); - verify(outboundOrders, never()).updateById((OutboundOrder) any()); - } - - @Test void pendingDispatchReviewBlocksPickingAndCompletionBeforeInventoryChanges() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(tasks.lockByDispatch("100", 1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING")); - org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货")) - .when(fleetBindings).requirePickingApproved(1L); - assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null))); - assertThrows(ResponseStatusException.class, () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("pending-complete"))); - org.mockito.Mockito.verifyNoInteractions(inventory, picks, attribution); - verify(orders, never()).updateById((DispatchOrder) any()); - } - - @Test void boundDispatchCannotDepartBeforeTripCoordination() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); order.setVehiclePlate("CA-100"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "按车次统一发车")) - .when(fleetBindings).requireCoordinatedDeparture(1L); - assertThrows(ResponseStatusException.class, () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now())); - org.mockito.Mockito.verifyNoInteractions(attribution, inventory, outboundOrders); - verify(orders, never()).updateById((DispatchOrder) any()); - } - - @Test - void successfulDeparturePersistsBusinessDepartureTime() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - DispatchItem item = item(21L, "100", 6); - item.setInboundCode("IN-1"); - DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L); - OutboundOrder outbound = new OutboundOrder(); - outbound.setId(1L); - outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND"); - OutboundPalletItem outboundItem = new OutboundPalletItem(); - outboundItem.setWarehousePalletId(41L); outboundItem.setInboundCode("IN-1"); - outboundItem.setPackageCount(6); outboundItem.setPickedPackageCount(0); outboundItem.setStatus("WAIT_PICK"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); - when(tasks.lockByDispatch("100", 1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); - when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); - when(outboundPalletItems.findByOutbound("100", 1L)).thenReturn(List.of(outboundItem)); - when(palletLocks.selectList(any())).thenReturn(List.of()); - - DispatchPdaResponses.Departed result = service.depart( - "WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()); - - assertEquals("DEPARTED", result.status()); - assertNotNull(order.getDepartAt()); - assertEquals(order.getDepartAt(), order.getUpdatedAt()); - verify(orders).updateById(order); - assertEquals("DEPARTED", order.getStatus()); - assertEquals("OUTBOUNDED", outbound.getStatus()); - assertEquals(6, outboundItem.getPickedPackageCount()); - assertEquals("PICKED", outboundItem.getStatus()); - verify(outboundPalletItems).updateById(outboundItem); - } - - @Test - void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception { - DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED"); - DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); - DispatchPdaResponses.Departed persisted = new DispatchPdaResponses.Departed("DSP-1", "DEPARTED", 12, false); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted)); - - DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request, java.time.LocalDateTime.now()); - - assertEquals("DEPARTED", result.status()); - assertTrue(result.replayed()); - } - - private DispatchPdaOperation operation(String type, Object request, Object result) throws Exception { - DispatchPdaOperation value = new DispatchPdaOperation(); - value.setTaskId(11L); value.setOperationType(type); value.setStatus("COMPLETED"); - value.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") - .digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)))); - value.setResultJson(objectMapper.writeValueAsString(result)); - return value; - } - - @Test void nativeBoxPickLocksOrderBeforeStockAndNeverUsesOrdinaryInventory() { - var order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); - order.setVehiclePlate("CA-100"); - var task = task(11L, "100", "WH-1", "PDA_PENDING"); - var item = item(21L, "100", 2); - item.setSourceStage("RETURN_RELABEL"); item.setSourceBusinessType("RELABEL_ORDER"); item.setOrderId(101L); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(tasks.lockByDispatch("100", 1L)).thenReturn(task); - when(items.selectById(21L)).thenReturn(item); - var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); carton.setId(71L); - when(returnStock.pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"), - org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"))).thenReturn(carton); - var result = service.pick("WH-1", "DSP-1", 21L, "7", "作业员", - new DispatchPdaRequests.Pick(null, 1, 3, "native-pick", null, "RC1#2-1")); - assertEquals(1L, result.pickedPackages()); - var sequence = org.mockito.Mockito.inOrder(attribution, returnStock, picks); - sequence.verify(attribution).prepareNativeReturn("100", 101L); - sequence.verify(returnStock).pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"), - org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1")); - sequence.verify(picks).insert(org.mockito.ArgumentMatchers.argThat((DispatchPdaPick p) -> p.getReturnCartonId().equals(71L) && p.getInventoryPositionId() == null)); - sequence.verify(attribution).attributeNativeReturn(org.mockito.ArgumentMatchers.eq("100"), any(), org.mockito.ArgumentMatchers.eq(101L)); - org.mockito.Mockito.verifyNoInteractions(inventory, positions); - } - - private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) { - DispatchOrder value = new DispatchOrder(); - value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); - value.setOverseasWarehouseId(warehouseId); value.setStatus(status); - return value; - } - - private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) { - DispatchPdaTask value = new DispatchPdaTask(); - value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L); - value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status); - return value; - } - - private static DispatchItem item(Long id, String customerId, int packages) { - DispatchItem value = new DispatchItem(); - value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L); - value.setOverseasWarehouseId("WH-1"); value.setWarehousePalletId(41L); value.setPackageCount(packages); - return value; + @Test void missingCustomerOutboundCannotBorrowAnotherCustomersDocument(){ + assertThatThrownBy(()->service.departWholeSources(context(List.of(cargo("101",11L,3,null))),actor,result("102"),Instant.now())).hasMessageContaining("本客户实际出库单");verifyNoInteractions(picks,source,attribution,fleet,relabels); } + private static DispatchContracts.CargoLine cargo(String customer,long order,long packages,String relabel){var c=mock(DispatchContracts.CargoLine.class);when(c.customerId()).thenReturn(customer);when(c.orderId()).thenReturn(order);when(c.packages()).thenReturn(packages);when(c.relabelNo()).thenReturn(relabel);return c;} + private static DispatchContracts.Context context(List cargo){var c=mock(DispatchContracts.Context.class);when(c.cargo()).thenReturn(cargo);when(c.dispatchId()).thenReturn(1L);when(c.dispatchNo()).thenReturn("DP-ONE");return c;} + private static DispatchDeliveryModels.DepartureResult result(String... customers){return new DispatchDeliveryModels.DepartureResult(Arrays.stream(customers).map(c->new DispatchDeliveryModels.OutboundReference(c,null,"OUT-"+c,"OUTBOUNDED",0,0)).toList(),List.of(),0);} } 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 index 9b3f1ec74..663b46821 100644 --- 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 @@ -62,7 +62,7 @@ class DispatchPickOrderAttributionServiceTest { when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1); DispatchPdaPick pick = pick(81L, 91L, 5L, 50L); - List result = service.attribute("t1", pick); + List result = service.attribute("t1", pick, null); assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L), new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result); @@ -94,7 +94,7 @@ class DispatchPickOrderAttributionServiceTest { when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(2L,102L,1L,1L), receipt(1L,101L,1L,1L),receipt(3L,102L,1L,1L))); when(orders.lockById(eq("t1"),anyLong())).thenAnswer(call -> new LastlegOrder()); - service().preparePicking("t1",91L,null); + service().preparePicking("t1",91L); var sequence=inOrder(attributions,orders); sequence.verify(attributions).availableReceipts("t1",91L); sequence.verify(orders).lockById("t1",101L);sequence.verify(orders).lockById("t1",102L); @@ -105,15 +105,15 @@ class DispatchPickOrderAttributionServiceTest { when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L))); var held=new LastlegOrder();held.setDetained(true); when(orders.lockById("t1",101L)).thenReturn(held).thenReturn(null); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L)); verifyNoInteractions(history); } @Test void changedSourceSetAfterLockingFailsClosed() { when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L))) .thenReturn(List.of(receipt(2L,102L,1L,1L))); when(orders.lockById("t1",101L)).thenReturn(new LastlegOrder()); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L)); verify(orders,never()).increasePicked(anyString(),anyLong(),anyLong()); } @@ -125,22 +125,22 @@ class DispatchPickOrderAttributionServiceTest { when(orders.increasePicked("t1", 101L, 2L)).thenReturn(1); var service = service(); assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 2L)), - service.attribute("t1", pick(81L, 91L, 2L, 0L), null, 201L)); + service.attribute("t1", pick(81L, 91L, 2L, 0L), 201L)); var capture = ArgumentCaptor.forClass(DispatchPdaPickOrder.class); verify(attributions).insert(capture.capture()); assertEquals(201L, capture.getValue().getReturnReceiptId()); assertEquals(null, capture.getValue().getReceiptId()); when(returnReceipts.picked("t1", 201L)).thenReturn(2L); assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> service.attribute("t1", pick(82L, 91L, 1L, 0L), null, 201L)); + () -> service.attribute("t1", pick(82L, 91L, 1L, 0L), 201L)); } - @Test void selectedReturnCannotUseFallbackOrLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() { + @Test void selectedReturnCannotLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() { var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L); when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt)); when(returnReceipts.lock("t1", 201L)).thenReturn(receipt); assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 101L, 201L)); + () -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 201L)); verify(attributions, never()).insert(any(DispatchPdaPickOrder.class)); verify(orders, never()).increasePicked(anyString(), anyLong(), anyLong()); verify(attributions, never()).availableReceipts(anyString(), anyLong()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java new file mode 100644 index 000000000..b70efee54 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 可独立用JDK17编译执行的边界合同,不启动Spring或连接环境。 */ +public final class DispatchPortCompileContract { + /** 合同执行器不允许实例化。 */ + private DispatchPortCompileContract() { } + + /** 检查下游使用的真实类型、版本域及单据数组,不模拟业务成功。 */ + public static void main(String[] args) throws Exception { + require(Stage.values().length == 5, "阶段必须恰好五项"); + require(Set.of(Status.values()).equals(Set.of(Status.ACTIVE, Status.CANCELLED)), "生命周期与阶段分离"); + for (Class request : new Class[] { + DispatchCommands.Update.class, DispatchCommands.AddCargo.class, + DispatchCommands.ReasonCommand.class, DispatchCommands.SubmitReview.class, + DispatchCommands.ReviewCommand.class, DispatchCommands.ReturnToReview.class, + DispatchCommands.Pick.class, DispatchCommands.LoadingComplete.class, + DispatchCommands.Depart.class, DispatchDeliveryModels.ArrangeDelivery.class, + DispatchPlanningModels.CreateContainer.class, DispatchPlanningModels.DraftSelection.class, + DispatchPlanningModels.DraftSnapshot.class, DispatchPlanningModels.DraftRelease.class, + DispatchPlanningModels.ConvertDraft.class,DispatchContracts.BatchAllocation.class,DispatchContracts.BatchReference.class + }) { + require(request.getMethod("recordVersion").getReturnType() == Long.class, "公开版本必须保留缺字段事实"); + require(!fields(request).contains("expectedVersion"), "禁止第二个公开版本别名"); + } + require(new DispatchCommands.Depart(0L, "contract-only", null).recordVersion() == 0, "版本零必须可表达"); + try { + new DispatchCommands.Depart(null, "contract-only", null); + throw new AssertionError("缺版本必须失败"); + } catch (IllegalArgumentException expected) { + require(expected.getMessage().contains("recordVersion"), "明确说明缺失版本"); + } + require(!fields(DispatchCommands.Update.class).contains("vehicleAppointment"), "更新不保留车辆预约字段或空占位"); + require(!fields(DispatchViews.DispatchInfo.class).contains("vehicleAppointment"), "排车信息不保留车辆预约字段或空占位"); + require(fields(DispatchViews.DispatchInfo.class).containsAll(Set.of("fbaAppointments","appointmentBatches")), "原FBA预约及批次仍保留"); + require(!fields(DispatchCommands.Create.class).contains("customerId"), "整车创建无单客户头部"); + require(!fields(DispatchContracts.Context.class).contains("customerId"), "整车上下文无伪客户"); + require(fields(DispatchContracts.CargoLine.class).contains("customerId"), "真实来源保留客户"); + require(!fields(DispatchCommands.LoadingComplete.class).contains("packages"), "装车确认不补录拣货数量"); + require(fields(DispatchDeliveryModels.ArrangementResult.class).containsAll(Set.of("outbounds", "deliveries")), + "安排返回完整客户单据数组"); + require(!fields(DispatchDeliveryModels.ArrangementResult.class).contains("outboundNo"), "无单出库别名"); + require(fields(DispatchContracts.CommandResult.class).containsAll(Set.of("replayed", "alreadyArranged")), + "原命令重放与引用既有安排分别表达"); + require(fields(DispatchViews.Detail.class).containsAll(Set.of("vehicleInfo", "dispatchInfo", "documents", "orderDetails")), + "详情四对象齐备"); + require(DispatchExecutionPort.class.getMethod("recordArrangement", DispatchContracts.Context.class, + DispatchContracts.Actor.class, DispatchContracts.CommandIdentity.class, + DispatchDeliveryModels.ArrangementFact.class, boolean.class).getReturnType() == DispatchContracts.CommandResult.class, + "派送写者可静态引用主流程记录端口"); + require(DispatchCompletionPort.class.getMethod("reconcile", DispatchDeliveryModels.Signal.class) + .getReturnType() == DispatchDeliveryModels.CompletionResult.class, "汇总消费明确持久信号身份"); + require(DispatchAppointmentAmendmentPort.class.getMethod("lockExistingAppointmentDispatches",DispatchContracts.Actor.class,java.util.List.class).getReturnType()==Void.TYPE, + "原预约消费者只取得有界全范围根锁,不再绑定客户排车"); + require(DispatchAppointmentAmendmentPort.class.getMethod("applyExistingAppointmentChange",DispatchContracts.Actor.class,String.class,DispatchCommands.ReasonCommand.class, + java.util.List.class,java.util.List.class,java.util.List.class,boolean.class).getReturnType()==DispatchContracts.CommandResult.class,"原预约改量返回公共不可变命令结果"); + System.out.println("DispatchPortCompileContract: PASS (JDK-only public contract; no HTTP/database/Spring)"); + } + + /** 按真实record组件检查公开字段,避免另维护一份模拟DTO。 */ + private static Set fields(Class type) { + return Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).collect(Collectors.toSet()); + } + + /** 合同不成立时直接失败,不能作为已通过编译报告。 */ + private static void require(boolean actual, String message) { + if (!actual) throw new AssertionError(message); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicContractTest.java new file mode 100644 index 000000000..ba120c390 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicContractTest.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.dispatch.port; + +import org.junit.jupiter.api.Test; + +/** Maven集成执行与独立编译使用同一真实合同。 */ +class DispatchPublicContractTest { + /** 检查公开字段和端口静态类型。 */ + @Test void compilesConsumerContract() throws Exception { DispatchPortCompileContract.main(new String[0]); } + + /** 验证Jackson缺字段和持久命令跨实例恢复。 */ + @Test void restoresTypedPersistentResults() throws Exception { DispatchPublicJsonContract.main(new String[0]); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java new file mode 100644 index 000000000..0928101c6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java @@ -0,0 +1,104 @@ +package com.baiyue.lastleg.dispatch.port; + +import com.baiyue.lastleg.dispatch.service.DispatchCommandCodec; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 使用真实Jackson和账本编解码器验证输入及跨实例恢复,不启动应用。 */ +public final class DispatchPublicJsonContract { + /** 测试执行器不允许实例化。 */ + private DispatchPublicJsonContract() { } + + /** 覆盖缺版本、显式零、移除车辆预约字段及所有动作结果持久JSON恢复。 */ + public static void main(String[] args) throws Exception { + ObjectMapper requests = mapper(); + for (Class request : new Class[] { + DispatchCommands.Update.class, DispatchCommands.AddCargo.class, + DispatchCommands.ReasonCommand.class, DispatchCommands.SubmitReview.class, + DispatchCommands.ReviewCommand.class, DispatchCommands.ReturnToReview.class, + DispatchCommands.Pick.class, DispatchCommands.LoadingComplete.class, + DispatchCommands.Depart.class, ArrangeDelivery.class, + DispatchPlanningModels.CreateContainer.class, DispatchPlanningModels.DraftSelection.class, + DispatchPlanningModels.DraftSnapshot.class, DispatchPlanningModels.DraftRelease.class, + DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class, + BatchAllocation.class,BatchReference.class + }) { + rejectsMissing(requests, request, "{}"); + rejectsMissing(requests, request, "{\"recordVersion\":null}"); + rejectsMissing(requests, request, "{\"recordVersion\":-1}"); + Object zero = requests.readValue("{\"recordVersion\":0}", request); + require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法"); + } + rejectsMissing(requests, DispatchCommands.Update.class, + "{\"recordVersion\":0,\"vehicleAppointment\":{\"referenceNo\":\"REMOVED\",\"recordVersion\":0}}"); + + Instant now = Instant.parse("2026-10-19T00:00:00Z"); + var outbound = new OutboundReference("101", "客户甲", "OB-CONTRACT", "OUTBOUNDED", 10, 1); + var destination = new Destination("FBA:ONT8", "FBA", "1", "ONT8", "目的仓", null, null, null, null); + var delivery = new DeliveryReference("101:FBA:ONT8", "101", "客户甲", "OB-CONTRACT", "DL-CONTRACT", + destination, "DELIVERING", 10, 1); + for (Action action : Action.values()) { + DispatchResults.Payload payload = switch (action) { + case PICK -> new DispatchResults.Picked(1L, 2L, null, 1, 2, false); + case LOADING_COMPLETE -> new DispatchResults.Loaded("load-hash", now, + new ConfirmedLoad("manifest-hash", List.of(new LoadingMeasurement(7L, 1, + new BigDecimal("10.000"), new BigDecimal("1.000"), 1)), "operator-a", now)); + case DEPART -> new DispatchResults.Departed(now, 10, List.of(outbound), List.of(delivery)); + case ARRANGE_DELIVERY -> new ArrangementResult("DP-CONTRACT", 0, "ARR-CONTRACT", "cargo-hash", + now, List.of(outbound), List.of(delivery)); + default -> new DispatchResults.Saved("DP-CONTRACT", List.of(1L, 2L)); + }; + var original = new CommandResult("DP-CONTRACT", action, "contract-" + action, 0, + Status.ACTIVE, Stage.AFTER_LOADING, now, Effect.APPLIED, + new Counts(2, 1, 10, 10, 0), payload, false, false); + String stored = new DispatchCommandCodec(mapper()).encode(original); + // 更换全部ObjectMapper/Codec实例后只从持久JSON恢复,不能依赖内存对象类型。 + var fresh = new DispatchCommandCodec(mapper()); + require(original.equals(fresh.decode(new String(stored.toCharArray()))), "全部动作必须恢复原不可变结果:" + action); + var replayed = fresh.replay(stored); + require(replayed.replayed() && replayed.recordVersion() == 0 && replayed.result().equals(payload) + && replayed.occurredAt().equals(now), "重放不能篡改动作版本/时间/结果:" + action); + } + var codec = new DispatchCommandCodec(mapper()); + try { + codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}"); + throw new AssertionError("损坏或错误动作结果不得恢复为成功"); + } catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); } + System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)"); + } + + /** 与正式Long按字符串输出约定共同验证账本恢复。 */ + private static ObjectMapper mapper() { + SimpleModule longs = new SimpleModule(); + longs.addSerializer(Long.class, ToStringSerializer.instance); + longs.addSerializer(Long.TYPE, ToStringSerializer.instance); + return new ObjectMapper().registerModule(new JavaTimeModule()).registerModule(longs) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } + + /** 使用真实JSON反序列化证明缺版本不能隐式变成零。 */ + private static void rejectsMissing(ObjectMapper mapper, Class type, String payload) throws Exception { + try { mapper.readValue(payload, type); } + catch (JsonProcessingException expected) { return; } + throw new AssertionError("请求必须拒绝缺失/非法版本或已移除字段:" + type.getSimpleName()); + } + + /** 离线合同失败时终止,不模拟接口成功。 */ + private static void require(boolean actual, String message) { + if (!actual) throw new AssertionError(message); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java new file mode 100644 index 000000000..9b80587f5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -0,0 +1,358 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.fleet.driver.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import java.lang.reflect.Modifier; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */ +public class DispatchAggregatePersistenceTest { + /** 移货、取消只释放本车原占用;重排后旧键恢复不得释放新车的活动实托键。 */ + @Test void removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims() throws Exception { + var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("remove-cancel-create",List.of(a,b)))); + long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class); + var removeRequest=new DispatchCommands.ReasonCommand(created.recordVersion(),"remove-original","重新分车"); + var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest)); + assertThat(removed.counts().packageCount()).isEqualTo(5);assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isZero(); + var newOwner=f.tx.execute(s->f.service.create(f.actor,f.create("new-owner",List.of(a)))); + assertThat(f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest)).replayed()).isTrue(); + assertThat(f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(f.repository.find(newOwner.dispatchNo()).getId()); + var cancelRequest=new DispatchCommands.ReasonCommand(removed.recordVersion(),"cancel-original","本趟取消"); + var cancelled=f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest)); + assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING); + assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip WHERE id=?",String.class,f.repository.find(created.dispatchNo()).getTripId())).isEqualTo("CANCELLED"); + f.tx.executeWithoutResult(s->f.service.create(f.actor,f.create("new-owner-two",List.of(b)))); + assertThat(f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest)).replayed()).isTrue(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2); + } + @Test void cancelledOutboundHistoryDoesNotBlockZeroPickCancellationButAnActiveDocumentDoes() throws Exception { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);var created=f.tx.execute(t->f.service.create(f.actor,f.create("cancel-history",List.of(source))));var root=f.repository.find(created.dispatchNo()); + var outbound=new com.baiyue.lastleg.delivery.entity.OutboundOrder();outbound.setId(123L);outbound.setCustomerId("101");outbound.setOutboundNo("OUT-HISTORY");outbound.setDispatchOrderId(root.getId());outbound.setStatus("WAIT_OUTBOUND");outbound.setPackageCount(3);outbound.setPalletCount(1);f.session.getMapper(OutboundOrderMapper.class).insert(outbound); + var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-history-original","取消未执行排车"); + assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request))).hasMessageContaining("关联出库"); + f.db.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=123"); + var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));assertThat(cancelled.status()).isEqualTo(Status.CANCELLED); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isOne();assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero(); + } + @Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception { + var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input)); + var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划"); + var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); + var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划"); + var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue(); + assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + } + /** 作业读取返回真实明细身份,路线保存重排实际站序且保留起运仓。 */ + @Test void operationItemsAndRouteUseActualIdsAndPersistedStopOrder() throws Exception { + var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("operation-reads",List.of(a,b)))); + var items=f.tx.execute(s->f.service.items(f.actor,created.dispatchNo())); + assertThat(items.items()).hasSize(2);assertThat(items.items().get(0).dispatchItemId()).isNotEqualTo(501L); + assertThat(items.items().get(0).sourceKey()).isEqualTo("PLAN:1");assertThat(items.recordVersion()).isEqualTo(created.recordVersion()); + var before=f.tx.execute(s->f.service.route(f.actor,created.dispatchNo())); + var keys=new ArrayList<>(before.stops().stream().skip(1).map(DispatchWorkModels.Stop::destinationKey).toList());Collections.reverse(keys); + var request=new DispatchWorkModels.RouteCommand(created.recordVersion(),"actual-route",keys,"调整卸货先后"); + var saved=f.tx.execute(s->f.service.reorderRoute(f.actor,created.dispatchNo(),request)); + var after=f.tx.execute(s->f.service.route(f.actor,created.dispatchNo())); + assertThat(after.stops().get(0)).isEqualTo(before.stops().get(0));assertThat(after.stops().stream().skip(1).map(DispatchWorkModels.Stop::destinationKey)).containsExactlyElementsOf(keys); + assertThat(after.recordVersion()).isEqualTo(saved.recordVersion()); + assertThat(f.tx.execute(s->f.service.reorderRoute(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThatThrownBy(()->f.tx.execute(s->f.service.reorderRoute(f.actor,created.dispatchNo(),new DispatchWorkModels.RouteCommand(saved.recordVersion(),"invent-route",List.of("FBA:INVENTED"),"缺失实际目的地")))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + } + /** 实际扫码和来源SQL核平后仍停在装车中,显式确认才进入装车后且保存原命令快照。 */ + @Test void loadingUsesActualSourcesMeasurementsAndNeverAutoCompletesFromCounters() throws Exception { + var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("load-create",List.of(a,b)))); + var approved=f.approve(created); + f.picked(1,"101",501,3);f.picked(2,"202",502,5); + f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8"); + f.db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no)VALUES(81,'101','WT-ACTUAL')"); + f.db.update("INSERT INTO ll_warehouse_transfer_pallet(transfer_order_id,customer_id,warehouse_pallet_id,carton_count,status,completed_at,gross_weight_kg,volume_cbm)VALUES(81,'101',501,3,'COMPLETED',CURRENT_TIMESTAMP,100,1)"); + var context=f.tx.execute(s->f.service.preview(f.actor,created.dispatchNo())); + assertThat(context.pickingReconciled()).isTrue();assertThat(context.cargo()).hasSize(2); + assertThat(context.cargo()).extracting(DispatchContracts.CargoLine::customerId).containsExactly("101","202"); + assertThat(context.stage()).isEqualTo(Stage.LOADING); + var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo())); + assertThat(preview.pallets().get(0).weightKg()).isEqualByComparingTo("100"); + assertThat(preview.pallets().get(0).evidence()).allMatch(DispatchPlanningModels.MeasurementEvidence::actual); + assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm","palletSpaces"); + var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1, + p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList(); + var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对"); + var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); + assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class); + assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); + assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); + } + + /** 合计相等但某项漏拣或归属缺失时,公共端口不能报告已核平。 */ + @Test void incompleteAttributionCannotPassLoadingThroughMatchingHeaderTotals() throws Exception { + var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("missing-source",List.of(a))));var approved=f.approve(created); + f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=3"); + f.db.update("UPDATE ll_dispatch_pda_pick_order SET packages=2"); + var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo())); + assertThat(preview.pickingReconciled()).isFalse();assertThat(preview.loadingComplete().allowed()).isFalse(); + assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平"); + assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); + } + /** 两级顺序、驳回重提、原结果恢复和零扫码退回均实际提交到同一整车。 */ + @Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source)))); + var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null))); + assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null)))).isInstanceOf(BizException.class); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划"))); + var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submit-2",null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null))); + var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null))); + assertThat(loading.stage()).isEqualTo(Stage.LOADING); + var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划"))); + assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(replay.recordVersion()).isEqualTo(rejected.recordVersion()); + var returned=f.tx.execute(s->f.service.returnToReview(f.actor,created.dispatchNo(),new DispatchCommands.ReturnToReview(loading.recordVersion(),"return-zero","重新核实资源"))); + assertThat(returned.stage()).isEqualTo(Stage.BEFORE_LOADING); + var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo())); + assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);assertThat(detail.reviews().dispatchReview().invalidatedReason()).isEqualTo("重新核实资源"); + assertThat(detail.reviews().loadingReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT review_round FROM ll_dispatch_order",Long.class)).isEqualTo(2); + } + + /** 主单计数即使漂移为零,已有扫码流水也不能借退回入口伪造未执行。 */ + @Test void realPickBlocksReturnEvenWhenHeaderCountersAreZero() throws Exception { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("picked-create",List.of(source)))); + f.db.update("UPDATE ll_dispatch_order SET stage='LOADING',review_state='APPROVED'"); + long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item",Long.class); + f.db.update("INSERT INTO ll_dispatch_pda_pick(customer_id,dispatch_item_id,packages)VALUES('101',?,1)",item); + assertThatThrownBy(()->f.tx.execute(s->f.service.returnToReview(f.actor,created.dispatchNo(),new DispatchCommands.ReturnToReview(created.recordVersion(),"forbidden-return","不能隐去扫码")))).isInstanceOf(BizException.class).hasMessageContaining("真实扫码"); + assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='forbidden-return'",Long.class)).isZero(); + } + /** 无客户头部、未知资源与计划不会生成假事实,原键不重复建车。 */ + @Test void emptyCreateHasOneRealTruckAndNoCustomerHeader() throws Exception { + var f=new Fixture(); var request=f.create("empty",List.of()); + var first=f.tx.execute(s->f.service.create(f.actor,request)); + var replay=f.tx.execute(s->f.service.create(f.actor,request)); + assertThat(first.recordVersion()).isZero(); assertThat(first.counts().packageCount()).isZero(); + assertThat(replay.replayed()).isTrue(); assertThat(replay.dispatchNo()).isEqualTo(first.dispatchNo()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ll_dispatch_order' AND COLUMN_NAME='customer_id'",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT planned_departure_at FROM ll_fleet_trip",LocalDateTime.class)).isNull(); + assertThat(f.db.queryForObject("SELECT vehicle_id FROM ll_fleet_trip",Long.class)).isNull(); + var detail=f.tx.execute(s->f.service.detail(f.actor,first.dispatchNo())); + assertThat(detail.stage()).isEqualTo(Stage.BEFORE_LOADING); + assertThat(detail.vehicleInfo().vehicle()).isNull(); assertThat(detail.orderDetails()).isEmpty(); + } + + /** 同仓双客户真实明细仅一张头;SQL分页按整车去重,详情保留已有预约读取。 */ + @Test void multiCustomerSelectionCreatesOneTruckAndKeepsAllDocumentsAndBatchReads() throws Exception { + var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5); + var original=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b)))); + assertThat(original.counts().itemCount()).isEqualTo(2); assertThat(original.counts().palletCount()).isEqualTo(2); + assertThat(original.counts().packageCount()).isEqualTo(8); + assertThat(f.db.queryForList("SELECT DISTINCT customer_id FROM ll_dispatch_item ORDER BY customer_id",String.class)).containsExactly("101","202"); + assertThat(f.db.queryForObject("SELECT COUNT(DISTINCT dispatch_order_id) FROM ll_dispatch_item",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isEqualTo(8); + var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.DISPATCH_REVIEW,ReviewStatus.NOT_SUBMITTED); + var page=f.tx.execute(s->f.service.page(f.actor,query)); + assertThat(page.total()).isOne(); assertThat(page.records()).hasSize(1); + assertThat(page.records().get(0).counts().packageCount()).isEqualTo(8); + assertThat(page.statistics().activeTotal()).isOne(); assertThat(page.statistics().stageCounts().get(Stage.BEFORE_LOADING)).isOne(); + assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isOne(); + + long dispatch=f.db.queryForObject("SELECT id FROM ll_dispatch_order",Long.class); + long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class); + f.db.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,confirmation_no,appointment_status,fba_warehouse_code,appointment_start_utc) VALUES(900,'AP-EXISTING','CONFIRM-REAL','SUCCESS','ONT8',?)",LocalDateTime.of(2026,10,19,10,0)); + f.db.update("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",dispatch,item); + var detail=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())); + assertThat(detail.orderDetails()).hasSize(2); assertThat(detail.dispatchInfo().appointmentBatches()).hasSize(1); + assertThat(detail.dispatchInfo().fbaAppointments().get(0).appointmentNo()).isEqualTo("CONFIRM-REAL"); + assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isEqualTo(Instant.parse("2026-10-19T10:00:00Z")); + var c=f.source(3,"101",503,"ONT8",2); + f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of()))); + var restored=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b)))); + assertThat(restored.replayed()).isTrue(); assertThat(restored.recordVersion()).isEqualTo(original.recordVersion()); + assertThat(restored.counts().packageCount()).isEqualTo(8); + assertThat(f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).counts().packageCount()).isEqualTo(10); + } + + /** 第N项已被另一车占用时,早先本车/来源/资源/账本全部回滚。 */ + @Test void laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems() throws Exception { + var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5); + f.tx.executeWithoutResult(s->f.service.create(f.actor,f.create("first-owner",List.of(b)))); + assertThatThrownBy(()->f.tx.execute(s->f.service.create(f.actor,f.create("conflicting-owner",List.of(a,b))))).isInstanceOf(BizException.class); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE warehouse_pallet_id=501",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='conflicting-owner'",Long.class)).isZero(); + } + + /** 实际MyBatis和Spring本机内存事务;不使用返回成功的仓储替身。 */ + public static final class Fixture { + public final JdbcTemplate db; public final TransactionTemplate tx; public final DispatchAggregateService service; + public final DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session; + public final DispatchAggregateRepository repository;public final DispatchExecutionRepository executionReads;public final FleetTripExecutionService fleet; + public final FleetTripService nativeFleetService; + public final DispatchContracts.Actor actor=new DispatchContracts.Actor("operator-test",ActorMode.PLATFORM,null); + final Map candidates=new HashMap<>(); + + /** 建立本切片最小表与真实Mapper,目录/选货来源明确是测试提供者。 */ + public Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); + db=new JdbcTemplate(data); transactions=new DataSourceTransactionManager(data);tx=new TransactionTemplate(transactions); tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + var mapperTypes=List.of(DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchExecutionReadMapper.class,DispatchCommandMapper.class,DispatchSourceMapper.class, + DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,FleetResourceMapper.class, + OutboundOrderMapper.class,DeliveryOrderMapper.class,DispatchCandidateMapper.class,ReturnDispatchCandidateMapper.class,DispatchPhysicalClaimMapper.class,DispatchItemSourceMapper.class); + mapperTypes.forEach(config::addMapper); + var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); factory.afterPropertiesSet(); + session=new SqlSessionTemplate(factory.getObject()); + var heads=new ArrayList(); + for(var field:DispatchAggregate.class.getDeclaredFields()) if(!Modifier.isStatic(field.getModifiers())) { + String column=field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT); + heads.add(column+" "+type(field.getType())+(column.equals("id")?" PRIMARY KEY":"")); + } + db.execute("CREATE TABLE ll_dispatch_order("+String.join(",",heads)+",UNIQUE(dispatch_no),UNIQUE(trip_id))"); + for(var mapper:List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,DispatchPhysicalClaimMapper.class,DispatchItemSourceMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,OutboundOrderMapper.class,DeliveryOrderMapper.class)) + for(var parent:mapper.getGenericInterfaces()) if(parent instanceof java.lang.reflect.ParameterizedType generic && generic.getRawType()==com.baomidou.mybatisplus.core.mapper.BaseMapper.class) { + var table=TableInfoHelper.getTableInfo((Class)generic.getActualTypeArguments()[0]); var columns=new ArrayList(); columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); + for(var field:table.getFieldList()) if(!table.getTableName().equals("ll_fleet_trip_dispatch") || !Set.of("customer_id","review_json").contains(field.getColumn())) columns.add(field.getColumn()+" "+type(field.getPropertyType())); + db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")"); + } + db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)"); + db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)"); + db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)"); + db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))"); + db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))"); + db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,dispatch_item_source_id BIGINT,packages BIGINT,product_quantity BIGINT)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_inbound_stock_receipt(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(64),inventory_balance_id BIGINT,order_id BIGINT,inventory_position_id BIGINT,received_packages BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))"); + db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(64),carton_mark VARCHAR(128),pallet_code VARCHAR(128),relabel_no VARCHAR(128),overseas_warehouse_id VARCHAR(64),total_quantity BIGINT,status VARCHAR(32))"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(64),transfer_no VARCHAR(128))"); + db.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT,carton_count INT,status VARCHAR(32),completed_at TIMESTAMP(3),gross_weight_kg DECIMAL(18,6),volume_cbm DECIMAL(18,6))"); + db.execute("CREATE TABLE ll_inventory_ledger(customer_id VARCHAR(64),pallet_id BIGINT,created_at TIMESTAMP(3),business_type VARCHAR(64))"); + db.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(32),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))"); + db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(128),customer_id VARCHAR(128),source_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)"); + db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,version INT DEFAULT 0,appointment_no VARCHAR(64),confirmation_no VARCHAR(128),appointment_status VARCHAR(32),fba_warehouse_code VARCHAR(128),appointment_start_utc TIMESTAMP(3))"); + db.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(128),active INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,order_id BIGINT,customer_id VARCHAR(128))"); + json=new ObjectMapper().registerModule(new JavaTimeModule()); + var fleetRepo=new FleetTripRepository(session.getMapper(FleetTripMapper.class),session.getMapper(FleetResourceMapper.class),session.getMapper(FleetTripHistoryMapper.class),session.getMapper(FleetTripDispatchMapper.class)); + var warehouses=mock(FleetWarehouseDirectory.class); when(warehouses.required(9L,"OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("9","WH9","测试起运仓","OVERSEAS")); + repository=new DispatchAggregateRepository(session.getMapper(DispatchAggregateMapper.class),session.getMapper(DispatchItemMapper.class),session.getMapper(DispatchReadMapper.class)); + var gateway=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(repository,session.getMapper(OutboundOrderMapper.class),session.getMapper(DeliveryOrderMapper.class), + mock(com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper.class),mock(com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository.class),mock(DispatchAggregateService.class),json); + var vehicles=mock(VehicleRepository.class); var drivers=mock(DriverService.class);var reservations=mock(FleetBookingReservations.class); + var fleetService=new FleetTripService(fleetRepo,vehicles,drivers,warehouses,json,gateway,reservations); + nativeFleetService=fleetService; + org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class)); + org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); + fleet=new FleetTripExecutionService(session.getMapper(FleetTripMapper.class),session.getMapper(FleetTripDispatchMapper.class),session.getMapper(FleetResourceMapper.class),gateway,fleetService,drivers,vehicles,json,reservations); + org.springframework.test.util.ReflectionTestUtils.setField(fleet,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); + var rules=mock(DispatchService.class); + var emptySnapshot=new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"EMPTY",true,List.of()); + when(rules.lockExecutionSources(anyString(),anyList())).thenReturn(emptySnapshot); + when(rules.lockWholeSources(eq("9"),anyList())).thenAnswer(invocation -> { + List request=invocation.getArgument(1); + return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList(); + }); + when(rules.lockAdditionalSources(eq("9"),anyList(),anyList())).thenAnswer(invocation -> { + List request=invocation.getArgument(1); + return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList(); + }); + service=new DispatchAggregateService(repository, + new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, + new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json); + org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class)); + org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); + org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class))); + executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"executionReads",executionReads); + var origins=new DispatchItemSourceRepository(session.getMapper(DispatchItemSourceMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"sourceOrigins",origins); + org.springframework.test.util.ReflectionTestUtils.setField(service,"relabelSources",new DispatchItemSourceService(origins,mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class),json)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"physicalClaims",new DispatchPhysicalClaimService(new DispatchPhysicalClaimRepository(session.getMapper(DispatchPhysicalClaimMapper.class)))); + } + + /** 提供者固定事实代表真实客户来源,调用请求自身没有客户字段。 */ + public DispatchCommands.CargoSelection source(long id,String customer,long pallet,String destination,int packages) { + var row=new DispatchPalletCandidate(); row.customerId(customer); row.customerName("测试客户"+customer); row.planId(id); row.planPalletId(id); row.orderId(9000+id); + row.warehousePalletId(pallet); row.overseasWarehouseId("9"); row.overseasWarehouseCode("WH9"); row.overseasWarehouseName("测试起运仓"); + row.orderNo("ORDER-"+id); row.orderRefs("ORDER-"+id); row.inboundCode("IN-"+id); row.palletNo("PL-"+pallet); row.containerNo("TEST-CN-"+id); + row.destinationType("FBA"); row.destinationWarehouseCode(destination); row.destinationWarehouseName(destination); row.fbaWarehouseCode(destination); + row.packageCount(packages); row.grossWeightKg(BigDecimal.TEN); row.volumeCbm(BigDecimal.ONE); row.volumeWeightKg(BigDecimal.ZERO); row.sourceStage("RECEIVED"); + candidates.put(id,row); return new DispatchCommands.CargoSelection(SourceType.INBOUND_PLAN,id,null,null,null,"a".repeat(64)); + } + + /** 空计划和资源是合法待完善事实,不补假日期或主档。 */ + public DispatchCommands.Create create(String key,List selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of()); } + + /** 临时资源不创建主档;文件提供者在此夹具隔离,归属HTTP网关另行验证。 */ + public DispatchCommands.Create configured(String key,List sources) { + var departure=Instant.now().plusSeconds(86400).truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方", + new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)), + new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789", + new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400))), + departure,null,null,sources,List.of()); + } + + /** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */ + public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) { + var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null))); + var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null))); + return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null))); + } + /** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */ + public void picked(long source,String customer,long pallet,int packages) { + long item=db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=?",Long.class,source); + db.update("INSERT INTO ll_order VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source); + db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?)",source,customer,"IN-"+source); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,?,?,?,?,?)",source,customer,item,pallet*10,packages,packages*2); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,?,?,?,?,?,?)",source,customer,source,9000+source,source,packages,packages*2); + } + + /** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */ + private static String type(Class type) { return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java new file mode 100644 index 000000000..bd34fb202 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.BatchAllocation; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DispatchAppointmentAllocationsTest { + @Test void explicitZeroIsPassedInProviderFieldOrderAndMixedVersionsAreRejectedBeforeLock() { + var provider=mock(AppointmentWorkDispatchPort.class);var service=new DispatchAppointmentAllocations(provider); + var a=new BatchAllocation(11L,"FBA_APPOINTMENT:7","101","ORDER:1","FBA:ONT8",3,0L); + service.lock(List.of(),List.of(a));verify(provider).lockBatches(Map.of("FBA_APPOINTMENT:7",0L)); + var item=new DispatchItem();item.setId(99L);item.setCustomerId("101");item.setPlanPalletId(11L);item.setSourceStage("RECEIVED");item.setPackageCount(3); + service.bind(6L,List.of(item),List.of(a));verify(provider).bindItems(6L,List.of(new AppointmentWorkDispatchPort.PhysicalItem(99L,"101",11L,"RECEIVED",3)), + List.of(new AppointmentWorkDispatchPort.Allocation(11L,"FBA_APPOINTMENT:7",0L,"101","ORDER:1","FBA:ONT8",3))); + clearInvocations(provider); + assertThatThrownBy(()->service.lock(List.of(),List.of(a,new BatchAllocation(12L,"FBA_APPOINTMENT:7","102","ORDER:2","FBA:ONT8",2,1L)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verifyNoInteractions(provider); + assertThatThrownBy(()->new BatchAllocation(11L,"FBA_APPOINTMENT:7","101","ORDER:1","FBA:ONT8",3,null)).isInstanceOf(IllegalArgumentException.class); + } + @Test void draftPrelocksAllGroupsInOneCallBeforeAnySourceOrTouchedWrite() { + var provider=mock(AppointmentWorkDispatchPort.class);var service=new DispatchAppointmentAllocations(provider); + var a=new BatchAllocation(11L,"FBA_APPOINTMENT:9","101","ORDER:1","FBA:ONT8",3,4L); + var b=new BatchAllocation(12L,"FBA_APPOINTMENT:2","102","ORDER:2","FBA:LAX9",2,0L); + service.lockCreates(List.of(create("a",List.of(a)),create("b",List.of(b)))); + verify(provider).lockBatches(new TreeMap<>(Map.of("FBA_APPOINTMENT:9",4L,"FBA_APPOINTMENT:2",0L))); + verifyNoMoreInteractions(provider); + } + @Test void bulkCandidatesUseEveryRealOwnerOnceAndRequireACompleteAuthorityResponse() { + var provider=mock(AppointmentWorkDispatchPort.class);var service=new DispatchAppointmentAllocations(provider); + var a=new com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate().customerId("101").planPalletId(11L); + var b=new com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate().customerId("102").planPalletId(12L); + var identities=List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",11L),new AppointmentWorkDispatchPort.CandidatePhysical("102",12L)); + var references=Map.of(identities.get(0),new AppointmentWorkDispatchPort.CandidateBatches(List.of(new AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:7","AP-7",0L,"101",List.of(new AppointmentWorkDispatchPort.BatchSource("ORDER:1","FBA:ONT8",3)))),List.of(),false),identities.get(1),new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of("原批次待确认"),false)); + when(provider.candidateBatches(identities)).thenReturn(references); + assertThat(service.candidates(List.of(a,b,a))).isEqualTo(references);verify(provider,times(1)).candidateBatches(identities); + when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整"); + } + private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List allocations) { + return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java index 8390f535f..495aa39e0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java @@ -1,57 +1,47 @@ package com.baiyue.lastleg.dispatch.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.lastleg.dispatch.mapper.*; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.fleet.trip.*; -import com.baiyue.lastleg.inboundplan.work.AppointmentWorkDispatchMapper; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.*; import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.transaction.support.TransactionTemplate; import java.util.*; -import java.time.LocalDateTime; -import java.math.BigDecimal; import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.mock; - +import static org.mockito.Mockito.*; +/** 主单内部桥接使用实际主单、明细、资源、占用、命令和历史事务;Provider SQL由其直接测试覆盖。 */ class DispatchAppointmentCancellationPersistenceTest { - /** 实际取消只解除有效预约运输占用,历史引用和后来重排的占用都保留。 */ - @Test void cancellationReleasesEffectiveBatchAllocationAndReplayCannotReleaseTheNewDispatch() throws Exception { - var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); - var db=new JdbcTemplate(data);var tx=new TransactionTemplate(new DataSourceTransactionManager(data)); - var config=new MybatisConfiguration();var mappers=List.of(DispatchOrderMapper.class,DispatchItemMapper.class,DispatchPalletLockMapper.class,OutboundOrderMapper.class,FleetTripDispatchMapper.class,AppointmentWorkDispatchMapper.class); - mappers.forEach(config::addMapper);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();var session=new SqlSessionTemplate(factory.getObject()); - for(var mapper:mappers)for(var parent:mapper.getGenericInterfaces()) { - if(!(parent instanceof java.lang.reflect.ParameterizedType p)||p.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue; - var table=TableInfoHelper.getTableInfo((Class)p.getActualTypeArguments()[0]);var columns=new ArrayList();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); - table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType())));db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")"); + @Test void removingOneCustomersOriginalCargoKeepsTruckAndSiblingAndRecoversTheOriginalCommand() throws Exception { + var s=new Scenario();var request=new DispatchCommands.ReasonCommand(s.created.recordVersion(),"appointment-remove-original","原预约整票移出"); + var removed=s.f.tx.execute(tx->{s.f.service.lockExistingAppointmentDispatches(s.f.actor,List.of(s.created.dispatchNo()));return s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),s.expected(),List.of(),true);}); + assertThat(removed.status()).isEqualTo(DispatchTypes.Status.ACTIVE);assertThat(removed.counts().packageCount()).isEqualTo(5); + assertThat(s.f.db.queryForObject("SELECT customer_id FROM ll_dispatch_item",String.class)).isEqualTo("102"); + assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne();assertThat(s.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isZero();assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=502",Long.class)).isOne(); + long history=s.count("ll_dispatch_history");assertThat(s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),s.expected(),List.of(),true)).replayed()).isTrue();assertThat(s.count("ll_dispatch_history")).isEqualTo(history); + verify(s.provider,times(1)).removeItem(s.root,s.item); + } + @Test void parentFailureRollsBackCargoClaimsReviewsHistoryAndChildLedger() throws Exception { + var s=new Scenario();long commands=s.count("ll_dispatch_command"),history=s.count("ll_dispatch_history"); + assertThatThrownBy(()->s.f.tx.execute(tx->{s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),new DispatchCommands.ReasonCommand(s.created.recordVersion(),"parent-rollback","原预约回滚"),s.physical(),s.expected(),List.of(),true);throw new IllegalStateException("parent rejected");})).hasMessageContaining("parent rejected"); + assertThat(s.count("ll_dispatch_item")).isEqualTo(2);assertThat(s.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Long.class)).isEqualTo(8);assertThat(s.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(s.created.recordVersion());assertThat(s.count("ll_dispatch_command")).isEqualTo(commands);assertThat(s.count("ll_dispatch_history")).isEqualTo(history); + assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2); + } + @Test void wrongOwnerPartialQuantityOrChangedOriginalAllocationCannotMutateAnyCargo() throws Exception { + var s=new Scenario();var request=new DispatchCommands.ReasonCommand(s.created.recordVersion(),"reject-wrong-original","范围核验"); + for(var physical:List.of(List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(s.item,"102",1L,"RECEIVED",3)),List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(s.item,"101",1L,"RECEIVED",2))))assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,physical,s.expected(),List.of(),true))).hasMessageContaining("完整数量"); + assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),List.of(),List.of(),true))).hasMessageContaining("分配集合");assertThat(s.count("ll_dispatch_item")).isEqualTo(2);verifyNoInteractions(s.provider); + } + @Test void realPickBlocksAppointmentRemovalEvenWhenMainCountersAreZero() throws Exception { + var s=new Scenario();s.f.picked(1,"101",501,1); + assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),new DispatchCommands.ReasonCommand(s.created.recordVersion(),"reject-picked","真实扫码"),s.physical(),s.expected(),List.of(),true))).hasMessageContaining("实际扫码");assertThat(s.count("ll_dispatch_item")).isEqualTo(2);verifyNoInteractions(s.provider); + } + private static final class Scenario { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();final DispatchContracts.CommandResult created;final long root,item;final AppointmentWorkDispatchPort provider=mock(AppointmentWorkDispatchPort.class); + Scenario() throws Exception { + created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-main",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5)))));root=f.repository.find(created.dispatchNo()).getId();item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class); + f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(900,0,'AP-ORIGINAL')");f.db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",root,item); + ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); } - db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(64),customer_id VARCHAR(64),source_key VARCHAR(64),destination_key VARCHAR(64),package_count INT)"); - db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(10,'C','DP-OLD','PENDING_DEPARTURE')"); - db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,package_count) VALUES(30,'C',10,1,4)"); - db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,status) VALUES(40,'C',10,'CANCELLED')"); - db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(50,'C',10,'ACTIVE','RECEIVED:501')"); - db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,30,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)"); - var allocations=session.getMapper(AppointmentWorkDispatchMapper.class); - assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4); - var service=new DispatchService();ReflectionTestUtils.setField(service,"orders",session.getMapper(DispatchOrderMapper.class));ReflectionTestUtils.setField(service,"items",session.getMapper(DispatchItemMapper.class));ReflectionTestUtils.setField(service,"locks",session.getMapper(DispatchPalletLockMapper.class));ReflectionTestUtils.setField(service,"outboundOrders",session.getMapper(OutboundOrderMapper.class));ReflectionTestUtils.setField(service,"fleetBindings",new FleetDispatchBindingGuard(session.getMapper(FleetTripDispatchMapper.class),new ObjectMapper()));ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class)); - tx.executeWithoutResult(s->service.cancel("DP-OLD")); - assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isZero(); - assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=10",Long.class)).isEqualTo(1); - db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(11,'C','DP-NEW','PENDING_DEPARTURE')"); - db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(51,'C',11,'ACTIVE','RECEIVED:501')"); - db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(11,31,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)"); - tx.executeWithoutResult(s->service.cancel("DP-OLD")); - assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4); - assertThat(db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE id=51",String.class)).isEqualTo("ACTIVE"); - assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=50",String.class)).isNull(); + List physical(){return List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(item,"101",1L,"RECEIVED",3));} + List expected(){return List.of(new DispatchContracts.BatchAllocation(1L,"FBA_APPOINTMENT:900","101","ORDER:9001","FBA:ONT8",3,0L));} + long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);} } - /** 测试表保留生产Mapper列映射,正式DDL由独立Schema合同验证。 */ - private static String sqlType(Class type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(4000)";} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index 6db7cd363..488b56e2f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -9,7 +9,8 @@ import static org.mockito.Mockito.when; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; +import java.util.Map; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; @@ -26,7 +27,7 @@ import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class DispatchBolPrintServiceTest { - @Mock private DispatchService dispatches; + @Mock private DispatchBolReadService dispatches; @Mock private PrintPdfClient print; @Mock private DomesticWarehouseDirectoryClient warehouses; @InjectMocks private DispatchBolPrintService service; @@ -50,17 +51,13 @@ class DispatchBolPrintServiceTest { @Test void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() { String warehouseId = "193000000000200003"; - DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", - null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, - null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", - 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), - List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", - "NYEXX2609110214", null, null, 1)), null, List.of(), null); + DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, + LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1)),Map.of()); var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); - when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(dispatches.read(detail.dispatchNo())).thenReturn(detail); when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address)))); when(print.generateDispatchBol(eq("internal-test-token"), eq("request-1"), any())) @@ -79,18 +76,11 @@ class DispatchBolPrintServiceTest { @Test void createsOneBolDocumentPerFbaWarehouse() { String warehouseId = "193000000000200003"; - DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", - null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, - null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", - 2, 2, 2, 0, 0, 2, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), - List.of( - new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", - "NYEXX2609110214", null, null, 1), - new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", - "NYEXX2609110215", null, null, 1)), null, List.of(), null); + DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, + LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1)),Map.of()); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); - when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(dispatches.read(detail.dispatchNo())).thenReturn(detail); when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of()))); when(print.generateDispatchBol(eq("internal-test-token"), eq("request-2"), any())) @@ -105,4 +95,22 @@ class DispatchBolPrintServiceTest { .map(PrintPdfClient.Document::fbaWarehouseCode).toList()) .containsExactly("ABE2", "ONT8"); } + @Test + void keepsTwoCustomersAtTheSameDestinationInSeparateBolDocuments() { + var first=line("101","ABE2","ABE2 Warehouse",3);var second=line("102","ABE2","ABE2 Warehouse",5); + var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,List.of(first,second), + Map.of(first.groupKey(),"CONF-A",second.groupKey(),"CONF-B")); + when(dispatches.read(detail.dispatchNo())).thenReturn(detail); + var warehouse=new DomesticWarehouseDirectoryClient.Warehouse(9L,0L,"WH9","Operating Warehouse","OVERSEAS","US","ACTIVE",null,null); + when(warehouses.activeWarehouse(9L,"internal-test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse,List.of()))); + when(print.generateDispatchBol(eq("internal-test-token"),eq("two-customers"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + service.generate(detail.dispatchNo(),"two-customers"); + var request=ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateDispatchBol(eq("internal-test-token"),eq("two-customers"),request.capture()); + assertThat(request.getValue().documents()).hasSize(2); + assertThat(request.getValue().documents().stream().map(PrintPdfClient.Document::isaNumber).toList()).containsExactly("CONF-A","CONF-B"); + } + private static DispatchBolData.Line line(String customer,String code,String name,int packages) { + return new DispatchBolData.Line(customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java new file mode 100644 index 000000000..098768f5a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.dispatch.service; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class DispatchBolReadPersistenceTest { + @Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception { + var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10),(2,20)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A'),(20,'102','CN-B')"); + f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','IN-A'),(200,'102',2000,'ONT8','ONT8','IN-B')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,0,3),(2,200,0,5)"); + f.db.update("INSERT INTO ll_order VALUES(1000,'101','CN-A'),(2000,'102','CN-B')");f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')"); + f.item(11,"101",1,null,"FBA",null,3);f.item(12,"102",2,null,"FBA",null,5); + var result=f.service.read("DP-ONE");assertThat(result.bolLines()).extracting(l->l.customerId()).containsExactly("101","102");assertThat(result.bolLines()).extracting(l->l.packageCount()).containsExactly(3,5);assertThat(result.bolLines()).extracting(l->l.fbaShipmentId()).containsExactly("SHIP-A","SHIP-B"); + assertThat(result.bolLines().get(0).groupKey()).isNotEqualTo(result.bolLines().get(1).groupKey()); + } + @Test void partialReturnReceiptPrintsOnlySelectedQuantityAndPrivateActualAddress() throws Exception { + var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A')");f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','ORIGINAL-IN')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,8,8)"); + f.item(11,"101",1,99L,"PRIVATE","Actual receiving street",2); + var result=f.service.read("DP-ONE");assertThat(result.bolLines()).singleElement().satisfies(l->{assertThat(l.packageCount()).isEqualTo(2);assertThat(l.destinationLabel()).isEqualTo("Actual receiving street");assertThat(l.warehouseNo()).isEqualTo("RETURN-IN");}); + } + private static final class Fixture { + final JdbcTemplate db;final DispatchBolReadService service; + Fixture() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),destination_type VARCHAR(32),delivery_address VARCHAR(128),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(64),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64),package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT)");db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT,destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64))");db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(64),shipment_no VARCHAR(64),fba_tracking_no VARCHAR(64))"); + var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class); + var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row);when(roots.batches(1)).thenReturn(List.of());service=new DispatchBolReadService(roots,mapper,new ObjectMapper()); + } + void item(long id,String customer,long plan,Long returned,String type,String address,int packages){db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,?,NULL,?,?,NULL,?,'ONT8','ONT8','ONT8',?,?)",id,customer,plan,returned,type,address,"P-"+id,returned==null?"IN-"+customer:"RETURN-IN",packages);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedgerPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedgerPersistenceTest.java new file mode 100644 index 000000000..10584b17f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedgerPersistenceTest.java @@ -0,0 +1,115 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.mapper.DispatchCommandMapper; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels; +import com.baiyue.lastleg.dispatch.port.DispatchResults; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.baiyue.lastleg.dispatch.repository.DispatchCommandRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** 实际H2和MyBatis验证账本持久身份;不连接环境或启动应用。 */ +public class DispatchCommandLedgerPersistenceTest { + /** 可独立执行同一组真实Mapper合同,Maven稳定后仍运行此JUnit测试。 */ + public static void main(String[] args) throws Exception { + var test = new DispatchCommandLedgerPersistenceTest(); + test.restoresFromCommittedRowAndRejectsAnotherActorOrPayload(); + test.separatesStrictIdentityFromArrangementBusiness(); + System.out.println("DispatchCommandLedgerPersistenceTest: PASS (real MyBatis/H2 persisted replay; actor/version conflict; separate business hash)"); + } + + /** 提交数据库后更换Session/Codec,仍只返回原版本结果并拒绝身份挪用。 */ + @Test void restoresFromCommittedRowAndRejectsAnotherActorOrPayload() throws Exception { + SqlSessionFactory factory = factory(); + var actor = new DispatchContracts.Actor("operator-a", DispatchTypes.ActorMode.PLATFORM, null); + var payload = new DispatchCommands.Depart(0L, "global-key", "实际发车"); + String hash = new DispatchCommandHasher(mapper()).command(actor, DispatchTypes.Action.DEPART, + "DP-CONTRACT", 0L, payload); + var identity = new DispatchContracts.CommandIdentity(DispatchTypes.Action.DEPART, "global-key", 0, hash); + var result = new DispatchContracts.CommandResult("DP-CONTRACT", DispatchTypes.Action.DEPART, "global-key", 1, + DispatchTypes.Status.ACTIVE, DispatchTypes.Stage.DELIVERING, Instant.parse("2026-10-19T00:00:00Z"), + DispatchTypes.Effect.APPLIED, new DispatchContracts.Counts(1, 1, 10, 10, 10), + new DispatchResults.Departed(Instant.parse("2026-10-19T00:00:00Z"), 10, List.of(), List.of()), false, false); + try (var session = factory.openSession(false)) { + var ledger = new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)), + new DispatchCommandCodec(mapper())); + assertTrue(ledger.replay(actor, "DP-CONTRACT", identity).isEmpty()); + ledger.append(1L, actor, identity, result); + session.commit(); + } + try (var session = factory.openSession(false)) { + var ledger = new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)), + new DispatchCommandCodec(mapper())); + var restored = ledger.replay(actor, "DP-CONTRACT", identity).orElseThrow(); + assertTrue(restored.replayed()); + assertEquals(1, restored.recordVersion()); + assertEquals(result.result(), restored.result()); + assertEquals(result.occurredAt(), restored.occurredAt()); + var other = new DispatchContracts.Actor("operator-b", DispatchTypes.ActorMode.PLATFORM, null); + assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, + () -> ledger.replay(other, "DP-CONTRACT", identity)).getResultCode()); + var changed = new DispatchContracts.CommandIdentity(DispatchTypes.Action.DEPART, "global-key", 1, hash); + assertEquals(ResultCode.CONFLICT, assertThrows(BizException.class, + () -> ledger.replay(actor, "DP-CONTRACT", changed)).getResultCode()); + assertEquals(ResultCode.CONFLICT, assertThrows(BizException.class, + () -> ledger.replay(actor, "DP-ANOTHER", identity)).getResultCode()); + } + } + + /** 严格版本和主体变动必须冲突,派送同业务不能因换键/版本变成另一安排。 */ + @Test void separatesStrictIdentityFromArrangementBusiness() { + var hasher = new DispatchCommandHasher(mapper()); + var actor = new DispatchContracts.Actor("operator-a", DispatchTypes.ActorMode.PLATFORM, null); + var other = new DispatchContracts.Actor("operator-b", DispatchTypes.ActorMode.PLATFORM, null); + var first = new DispatchDeliveryModels.ArrangeDelivery(0L, "key-a", "cargo", "loading", List.of()); + var second = new DispatchDeliveryModels.ArrangeDelivery(1L, "key-b", "cargo", "loading", List.of()); + assertNotEquals(hasher.command(actor, DispatchTypes.Action.ARRANGE_DELIVERY, "DP", 0L, first), + hasher.command(actor, DispatchTypes.Action.ARRANGE_DELIVERY, "DP", 1L, second)); + assertNotEquals(hasher.command(actor, DispatchTypes.Action.ARRANGE_DELIVERY, "DP", 0L, first), + hasher.command(other, DispatchTypes.Action.ARRANGE_DELIVERY, "DP", 0L, first)); + assertEquals(hasher.arrangement("DP", first, null), hasher.arrangement("DP", second, null)); + assertNotEquals(hasher.arrangement("DP", first, null), hasher.arrangement("OTHER", first, null)); + assertEquals(hasher.source(Map.of("groups", List.of(Map.of("key", "b"), Map.of("key", "a")))), + hasher.source(Map.of("groups", List.of(Map.of("key", "a"), Map.of("key", "b"))))); + assertNotEquals(hasher.source(Map.of("stops", List.of("A", "B"))), + hasher.source(Map.of("stops", List.of("B", "A")))); + } + + /** 本机内存库只建立受影响表,生产数据访问仍走真实Mapper。 */ + private static SqlSessionFactory factory() throws Exception { + var source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:dispatch_command_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var connection = source.getConnection(); var statement = connection.createStatement()) { + statement.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64)," + + "action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) NOT NULL UNIQUE," + + "record_version BIGINT NOT NULL,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))"); + } + var config = new Configuration(new Environment("contract", new JdbcTransactionFactory(), source)); + config.addMapper(DispatchCommandMapper.class); + return new SqlSessionFactoryBuilder().build(config); + } + + /** 使用新实例模拟进程内缓存已经全部丢失。 */ + private static ObjectMapper mapper() { + return new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java new file mode 100644 index 000000000..5f60b8a8f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.*; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 实际父锁、CAS、历史和信号事务验证,派送事实提供者在此隔离并由其本域测试覆盖。 */ +class DispatchCompletionPersistenceTest { + /** CAS之后信号确认失败,两者及完成证据一起回滚;重试只推进一次。 */ + @Test void stageAndSignalAcknowledgeRollbackTogetherThenRetryExactlyOnce() throws Exception { + var fixture=new CompletionFixture();fixture.allSigned(); + doAnswer(call->{fixture.f.db.update("UPDATE completion_test_signal SET state='PROCESSED' WHERE id=1");throw new IllegalStateException("模拟确认后故障");}) + .when(fixture.signals).acknowledge(any(),any()); + assertThatThrownBy(()->fixture.port.reconcile(fixture.signal)).isInstanceOf(IllegalStateException.class); + assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("DELIVERING"); + assertThat(fixture.f.db.queryForObject("SELECT state FROM completion_test_signal WHERE id=1",String.class)).isEqualTo("PENDING"); + assertThat(fixture.completedHistory()).isZero(); + fixture.acknowledges();var changed=fixture.port.reconcile(fixture.signal);var repeated=fixture.port.reconcile(fixture.signal); + assertThat(changed.changed()).isTrue();assertThat(changed.stage()).isEqualTo(Stage.DELIVERED); + assertThat(repeated.changed()).isFalse();assertThat(repeated.recordVersion()).isEqualTo(changed.recordVersion()); + assertThat(fixture.completedHistory()).isOne(); + assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + } + /** 部分签收只确认本信号,不把异常或返回数凑作全签收;后续版本独立触发完成。 */ + @Test void partialFactsStayDeliveringUntilLaterNormallySignedSignal() throws Exception { + var fixture=new CompletionFixture();fixture.acknowledges();fixture.normalFacts(false); + var partial=fixture.port.reconcile(fixture.signal);assertThat(partial.changed()).isFalse();assertThat(partial.stage()).isEqualTo(Stage.DELIVERING); + assertThat(fixture.f.db.queryForObject("SELECT state FROM completion_test_signal WHERE id=1",String.class)).isEqualTo("PROCESSED"); + fixture.allSigned();fixture.f.db.update("INSERT INTO completion_test_signal VALUES(2,'PENDING')"); + var next=new Signal(2L,fixture.number,"DELIVERY-A",2,0,Instant.now()); + assertThat(fixture.port.reconcile(next).changed()).isTrue();assertThat(fixture.completedHistory()).isOne(); + } + + /** 构造真实主单及实际来源,模拟已发车事实;不声称此夹具执行了发车库存或派送创建。 */ + private static final class CompletionFixture { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchSignalPort signals=mock(DispatchSignalPort.class); + final DispatchCompletionPort port;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5); + /** 使用真实Spring事务代理,REQUIRES_NEW与回滚均按生产注解执行。 */ + CompletionFixture() throws Exception { + var selected=f.source(1,"101",501,"ONT8",3);var created=f.tx.execute(s->f.service.create(f.actor,f.configured("completion-create",List.of(selected))));number=created.dispatchNo(); + f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.ofInstant(signedAt.minusSeconds(60),ZoneOffset.UTC)); + f.db.execute("CREATE TABLE completion_test_signal(id BIGINT PRIMARY KEY,state VARCHAR(32))");f.db.update("INSERT INTO completion_test_signal VALUES(1,'PENDING')"); + signal=new Signal(1L,number,"DELIVERY-A",1,0,Instant.now()); + var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json); + var proxy=new ProxyFactory(target);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));port=(DispatchCompletionPort)proxy.getProxy(); + } + /** 父事务中的信号SQL参与同一个DataSource事务。 */ + void acknowledges(){doAnswer(call->{Signal current=call.getArgument(0);f.db.update("UPDATE completion_test_signal SET state='PROCESSED' WHERE id=?",current.signalId());return null;}).when(signals).acknowledge(any(),any());} + /** 正常事实策略本身由派送域直接测试,本测试只验证主阶段消费职责。 */ + void normalFacts(boolean normal){when(deliveries.normalDeliveryFacts(any())).thenReturn(new NormalDeliveryFacts(number,"test-normal-facts",normal,normal?signedAt:null,List.of("DELIVERY-A"),List.of(),normal?List.of():List.of("尚未全部正常签收")));} + /** 全签收与部分签收使用不同的实际提供者结果。 */ + void allSigned(){normalFacts(true);} + /** 唯一完成历史包含完整当时事实,重复提醒不得追加。 */ + long completedHistory(){return f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='NORMAL_DELIVERY_COMPLETED'",Long.class);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorkerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorkerTest.java new file mode 100644 index 000000000..9a038b1df --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorkerTest.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchSignalPort; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.assertj.core.api.Assertions.*; + +/** 调度只编排已回滚事务后的退避,不持信号锁跨入根锁。 */ +class DispatchDeliveryCompletionWorkerTest { + /** 一车失败不吞掉后一车,且先完成失败事务调用才写退避。 */ + @Test void backsOffAfterFailureAndContinuesOtherSignals() { + var completion=mock(DispatchCompletionService.class);var signals=mock(DispatchSignalPort.class); + var first=new Signal(1L,"D-A","L-A",1,0,Instant.now());var second=new Signal(2L,"D-B","L-B",2,4,Instant.now()); + when(signals.due(any(),eq(100))).thenReturn(List.of(first,second));when(completion.reconcile(first)).thenThrow(new IllegalStateException("已回滚")); + var before=Instant.now();new DispatchDeliveryCompletionWorker(completion,signals).consume(); + var order=inOrder(completion,signals);order.verify(completion).reconcile(first);order.verify(signals).failed(eq(first),argThat(t->t.isAfter(before.plusSeconds(4))&&t.isBefore(Instant.now().plusSeconds(6))),eq("DISPATCH_COMPLETION_FAILED"));order.verify(completion).reconcile(second); + verify(signals,never()).acknowledge(any(),any()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java new file mode 100644 index 000000000..55e79d820 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java @@ -0,0 +1,92 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 实际根/资源/来源锁、版本及事务;派送单据和来源应用提供者在此受控,整链另行验证。 */ +class DispatchDeparturePersistenceTest { + /** 同版本平台和PDA竞争仅一个命令成功,原键重放不再次发布来源或发运。 */ + @Test void platformAndPdaDepartureRaceAppliesOnlyOneCommand() throws Exception { + var fixture=new Departure();var platformRequest=new DispatchCommands.Depart(fixture.version,"platform-depart","实际发车"); + var pdaRequest=new DispatchCommands.Depart(fixture.version,"pda-depart","实际发车"); + var start=new CountDownLatch(1);var executor=Executors.newFixedThreadPool(2); + try { + var a=executor.submit(()->fixture.tryDepart(start,fixture.f.actor,platformRequest)); + var b=executor.submit(()->fixture.tryDepart(start,new Actor("picker",ActorMode.PDA,"9"),pdaRequest)); + start.countDown();assertThat(List.of(a.get(20,TimeUnit.SECONDS),b.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false); + } finally {executor.shutdownNow();} + assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("DELIVERING"); + assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT"); + assertThat(fixture.f.db.queryForObject("SELECT shipped_package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(8); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='DEPART'",Long.class)).isOne(); + String winningKey=fixture.f.db.queryForObject("SELECT idempotency_key FROM ll_dispatch_command WHERE action='DEPART'",String.class); + var winningActor="platform-depart".equals(winningKey)?fixture.f.actor:new Actor("picker",ActorMode.PDA,"9"); + var recovered=fixture.f.tx.execute(s->fixture.service.depart(winningActor,fixture.number,new DispatchCommands.Depart(fixture.version,winningKey,"实际发车"))); + assertThat(recovered.replayed()).isTrue(); + verify(fixture.deliveries,times(1)).markDeparted(any(),any(),any(),anyString());verify(fixture.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + var order=inOrder(fixture.deliveries,fixture.sourceMapper,fixture.sourceWorkflow);order.verify(fixture.deliveries).departureFacts(any(),eq(true)); + order.verify(fixture.sourceMapper).lockOrder("101",9001L);order.verify(fixture.sourceMapper).lockOrder("202",9002L);order.verify(fixture.deliveries).markDeparted(any(),any(),any(),anyString());order.verify(fixture.sourceWorkflow).departWholeSources(any(),any(),any(),any()); + } + /** 后续来源同步失败时单据、资源、主单及账本一起回滚。 */ + @Test void sourceFailureRollsBackAllCustomerDocumentsAndTruckDeparture() throws Exception { + var fixture=new Departure();doThrow(new IllegalStateException("模拟来源更新失败")).when(fixture.sourceWorkflow).departWholeSources(any(),any(),any(),any()); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.depart(fixture.f.actor,fixture.number,new DispatchCommands.Depart(fixture.version,"failed-depart",null)))).isInstanceOf(IllegalStateException.class); + assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("AFTER_LOADING"); + assertThat(fixture.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsOnly("WAIT_OUTBOUND"); + assertThat(fixture.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsOnly("WAIT_DELIVERY"); + assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='DEPART'",Long.class)).isZero(); + } + + /** 真建单、两级审核、真实归属读取、显式装车及主流程安排记录,隔离两个跨域应用提供者。 */ + private static final class Departure { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class); + final DispatchSourceMapper sourceMapper;final DispatchDepartureService service;final String number;final long version; + final List outbounds;final List deliveryRefs; + /** 所有状态均经过本域真实命令,跨域提供者只写本测试实际表以验证共同事务。 */ + Departure() throws Exception { + var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("depart-create",List.of(a,b))));number=created.dispatchNo();var approved=f.approve(created); + f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8"); + var loading=f.tx.execute(s->f.service.loading(f.actor,number));var measurements=loading.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList(); + f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),measurements,"实际实载"))); + var context=f.tx.execute(s->f.service.preview(f.actor,number)); + outbounds=List.of(new OutboundReference("101","测试客户101","OUT-A","WAIT_OUTBOUND",3,1),new OutboundReference("202","测试客户202","OUT-B","WAIT_OUTBOUND",5,1)); + deliveryRefs=List.of(new DeliveryReference("GROUP-A","101","测试客户101","OUT-A","DL-A",context.cargo().get(0).destination(),"WAIT_DELIVERY",3,1),new DeliveryReference("GROUP-B","202","测试客户202","OUT-B","DL-B",context.cargo().get(1).destination(),"WAIT_DELIVERY",5,1)); + var arrangement=new ArrangementResult(number,context.recordVersion()+1,"ARR-TEST",context.cargoHash(),Instant.now(),outbounds,deliveryRefs); + var identity=new CommandIdentity(Action.ARRANGE_DELIVERY,"depart-arrangement",context.recordVersion(),"a".repeat(64)); + version=f.tx.execute(s->f.service.recordArrangement(context,f.actor,identity,new ArrangementFact("ARR-TEST","b".repeat(64),context.cargoHash(),context.loadingCompletionHash(),arrangement),false)).recordVersion(); + for(int n=0;n{f.db.queryForList("SELECT id FROM ll_outbound_order ORDER BY id FOR UPDATE");f.db.queryForList("SELECT id FROM ll_delivery_order ORDER BY id FOR UPDATE");return new DepartureFacts(number,"ARR-TEST",context.cargoHash(),context.loadingCompletionHash(),"b".repeat(64),outbounds,deliveryRefs,context.cargo(),new Gate(true,null,null));}); + when(deliveries.markDeparted(any(),any(),any(),anyString())).thenAnswer(call->{ + f.db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");f.db.update("UPDATE ll_delivery_order SET status='DELIVERING'"); + return new DepartureResult(outbounds.stream().map(o->new OutboundReference(o.customerId(),o.customerName(),o.outboundNo(),"OUTBOUNDED",o.packageCount(),o.palletCount())).toList(), + deliveryRefs.stream().map(d->new DeliveryReference(d.groupKey(),d.customerId(),d.customerName(),d.outboundNo(),d.deliveryNo(),d.destination(),"DELIVERING",d.packageCount(),d.palletCount())).toList(),8); + }); + var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),sourceMapper); + var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); + service=new DispatchDepartureService(f.service,f.repository,sources,deliveries,sourceWorkflow,f.fleet,commands,new DispatchCommandHasher(f.json),f.json);clearInvocations(sourceMapper); + } + /** 两端各自持真实数据库事务等待同一资源根锁,版本冲突必须完整失败。 */ + boolean tryDepart(CountDownLatch start,Actor actor,DispatchCommands.Depart request) throws Exception {start.await(5,TimeUnit.SECONDS);try{f.tx.execute(s->service.depart(actor,number,request));return true;}catch(com.baiyue.common.core.exception.BizException expected){return false;}} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java new file mode 100644 index 000000000..83efefece --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java @@ -0,0 +1,580 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchDraft; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 真实MyBatis/内存数据库/Spring事务代理;来源与整车提供者在边界隔离,不连接运行环境。 */ +class DispatchDraftPersistenceTest { + private final Actor owner=new Actor("7",ActorMode.PLATFORM,null); + private final Actor other=new Actor("8",ActorMode.PLATFORM,null); + private final DispatchService sources=mock(DispatchService.class); + private final DispatchAggregateService aggregate=mock(DispatchAggregateService.class); + private final DispatchSourceRepository sourceClaims=mock(DispatchSourceRepository.class); + private final AppointmentWorkDispatchPort appointmentProvider=mock(AppointmentWorkDispatchPort.class); + private final Map versions=new ConcurrentHashMap<>(); + private final Map physical=new ConcurrentHashMap<>(); + private final Map> completeGroups=new ConcurrentHashMap<>(); + private final Map choices=new ConcurrentHashMap<>(); + private final List calls=Collections.synchronizedList(new ArrayList<>()); + private DriverManagerDataSource dataSource; + private JdbcTemplate db; + private DispatchDraftRepository repository; + private DispatchDraftService service; + private DispatchPhysicalClaimService physicalClaims; + private PhysicalPalletSerialPort palletSerial; + private DispatchSourceMapper actualClaimMapper; + private TransactionTemplate transactions; + private Clock clock=Clock.systemUTC(); + private Runnable beforeSourceLocks=()->{ }; + private final AtomicInteger commands=new AtomicInteger(); + + /** 仅建立本包真实表和主单边界写入探针,模拟同数据库提供者共同加入外层事务。 */ + @BeforeEach void setup()throws Exception { + dataSource=new DriverManagerDataSource("jdbc:h2:mem:draft_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + db=new JdbcTemplate(dataSource); + db.execute("CREATE TABLE ll_dispatch_draft(id BIGINT PRIMARY KEY,draft_token VARCHAR(128) UNIQUE,operator_id VARCHAR(128),warehouse_id VARCHAR(64),record_version BIGINT,sources_json CLOB,plan_ids_json CLOB,containers_json CLOB,status VARCHAR(32),expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_dispatch_draft_container(id BIGINT PRIMARY KEY,draft_id BIGINT,draft_token VARCHAR(128),sequence_no INT,capacity INT,source_keys_json CLOB,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(draft_id,sequence_no))"); + db.execute("CREATE TABLE ll_dispatch_draft_command(id BIGINT PRIMARY KEY,idempotency_key VARCHAR(128) UNIQUE,operator_id VARCHAR(128),action VARCHAR(32),target_token VARCHAR(128),request_hash CHAR(64),result_json CLOB,created_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,physical_lock_id BIGINT,customer_id VARCHAR(64),source_pallet_key VARCHAR(128),active_key VARCHAR(128),plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,return_receipt_id BIGINT,dispatch_order_id BIGINT,draft_container_id BIGINT,draft_token VARCHAR(128),operator_id VARCHAR(128),status VARCHAR(32),locked_at TIMESTAMP,expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(customer_id,active_key))"); + db.execute("CREATE TABLE ll_dispatch_physical_pallet_lock(id BIGINT PRIMARY KEY,warehouse_id VARCHAR(128),warehouse_pallet_id BIGINT NOT NULL,active_warehouse_pallet_id BIGINT UNIQUE,dispatch_order_id BIGINT,draft_token VARCHAR(128),draft_container_id BIGINT,operator_id VARCHAR(128),status VARCHAR(32),expires_at TIMESTAMP,locked_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); + db.execute("CREATE TABLE draft_created_dispatch(dispatch_no VARCHAR(128) PRIMARY KEY,cargo_count INT)"); + db.execute("CREATE TABLE draft_source_gate(id BIGINT PRIMARY KEY)"); + db.execute("CREATE TABLE ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + for(var mapper:List.of(DispatchDraftMapper.class,DispatchDraftContainerMapper.class,DispatchDraftCommandMapper.class,DispatchDraftClaimMapper.class,DispatchPhysicalClaimMapper.class,PhysicalPalletSerialMapper.class,DispatchSourceMapper.class))config.addMapper(mapper); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(dataSource);factory.setConfiguration(config); + var session=new SqlSessionTemplate(factory.getObject()); + repository=new DispatchDraftRepository(session.getMapper(DispatchDraftMapper.class),session.getMapper(DispatchDraftContainerMapper.class), + session.getMapper(DispatchDraftCommandMapper.class),session.getMapper(DispatchDraftClaimMapper.class)); + var physicalTarget=new DispatchPhysicalClaimService(new DispatchPhysicalClaimRepository(session.getMapper(DispatchPhysicalClaimMapper.class))); + var physicalProxy=new ProxyFactory(physicalTarget);physicalProxy.setProxyTargetClass(true); + physicalProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),new AnnotationTransactionAttributeSource())); + physicalClaims=(DispatchPhysicalClaimService)physicalProxy.getProxy(); + var serialTarget=new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(session.getMapper(PhysicalPalletSerialMapper.class))); + var serialProxy=new ProxyFactory(serialTarget);serialProxy.setProxyTargetClass(true); + serialProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),new AnnotationTransactionAttributeSource())); + palletSerial=(PhysicalPalletSerialPort)serialProxy.getProxy(); + actualClaimMapper=session.getMapper(DispatchSourceMapper.class); + transactions=new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + service=proxy(); + for(long id=1;id<=40;id++){versions.put(id,"version-"+id);physical.put(id,1000+id);db.update("INSERT INTO draft_source_gate VALUES(?)",id);} + when(sources.selectionGroup(anyString(),any())).thenAnswer(call->group(call.getArgument(1))); + when(sources.lockDraftSources(anyString(),anyList())).thenAnswer(call->{ + calls.add("sources");String warehouse=call.getArgument(0);List selections=call.getArgument(1); + beforeSourceLocks.run(); + var result=new ArrayList();var selectedKeys=selections.stream().map(DispatchService::wholeSourceKey).collect(java.util.stream.Collectors.toSet()); + for(var s:selections){long sourceId=sourceId(s); + if(!selectedKeys.containsAll(group(s).sources().stream().map(SelectionGroupSource::sourceKey).toList()))throw new IllegalStateException("真实完整组缺少成员"); + db.queryForObject("SELECT id FROM draft_source_gate WHERE id=? FOR UPDATE",Long.class,sourceId); + if(!Objects.equals(s.sourceVersion(),versions.get(sourceId)))throw new IllegalStateException("真实来源版本已变化"); + if(!"92".equals(warehouse))throw new IllegalStateException("真实来源仓不一致"); + result.add(whole(s));} + // 受控来源入口仍遵循真实生产顺序:先完整来源身份锁,再共同实托串行点。 + palletSerial.lock(selections.stream().map(DispatchCommands.CargoSelection::warehousePalletId).filter(Objects::nonNull).distinct().sorted().toList()); + return result; + }); + when(sourceClaims.claims(anyString(),anyString(),any())).thenReturn(List.of()); + when(appointmentProvider.candidateBatches(anyList())).thenAnswer(call->{List items=call.getArgument(0);var result=new LinkedHashMap();for(var item:items)result.put(item,new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of(),false));return result;}); + doAnswer(call->{calls.add("resources");return null;}).when(aggregate).lockDraftCreationResources(any(),anyList()); + when(aggregate.create(any(),any())).thenAnswer(call->{calls.add("create");DispatchCommands.Create request=call.getArgument(1);String no="DS-"+request.idempotencyKey(); + db.update("INSERT INTO draft_created_dispatch(dispatch_no,cargo_count) VALUES(?,?)",no,request.cargo().size()); + return result(no,request.idempotencyKey(),request.cargo().size());}); + } + + /** 新实例只依赖数据库命令结果,关闭内存对象也能恢复创建及原布局结果。 */ + @Test void restartRecoversOriginalResultsAndRejectsChangedActorVersionOrPayload(){ + var request=new CreateDraft("create","92");var draft=service.createDraft(owner,request); + var group=new CreateContainer(0L,"group",draft.draftToken(),15);var changed=service.createContainer(owner,group); + assertThat(proxy().createDraft(owner,request)).isEqualTo(draft); + assertThat(proxy().createContainer(owner,group)).isEqualTo(changed); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft",Integer.class)).isEqualTo(1); + assertThatThrownBy(()->service.createDraft(other,request)).isInstanceOf(BizException.class); + assertThatThrownBy(()->service.createContainer(owner,new CreateContainer(1L,"group",draft.draftToken(),15))).isInstanceOf(BizException.class); + assertThatThrownBy(()->service.createContainer(owner,new CreateContainer(0L,"group",draft.draftToken(),30))).isInstanceOf(BizException.class); + assertThatThrownBy(()->service.createContainer(other,new CreateContainer(1L,"foreign",draft.draftToken(),15))).isInstanceOf(BizException.class); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key='foreign'",Integer.class)).isZero(); + assertThat(service.drafts(other)).isEmpty(); + } + + /** 一容器可保存多个真实客户和目的地,布局序号互换不冲撞唯一键。 */ + @Test void snapshotSwapsUniqueSequencesAndMovesOnlyPreviouslyClaimedSources(){ + var state=empty(2);state=select(state,0,1);state=select(state,0,2);state=select(state,1,3); + assertThat(state.sources()).extracting(Candidate::customerId).containsExactly("101","102","101"); + var a=state.containers().get(0);var b=state.containers().get(1); + state=service.saveSnapshot(owner,new DraftSnapshot(state.recordVersion(),key(),state.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),2,15,b.sourceKeys()),new DraftContainer(b.draftContainerId(),1,15,a.sourceKeys())))); + assertThat(state.containers()).extracting(DraftContainer::draftContainerId).containsExactly(b.draftContainerId(),a.draftContainerId()); + assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_pallet_lock WHERE active_key='PLAN:1'",Long.class)).isEqualTo(b.draftContainerId()); + var original=state;String badKey=key(); + assertThatThrownBy(()->service.saveSnapshot(owner,new DraftSnapshot(original.recordVersion(),badKey,original.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),1,15,List.of("PLAN:999")),new DraftContainer(b.draftContainerId(),2,15,List.of()))))).isInstanceOf(BizException.class); + assertThat(service.drafts(owner).get(0)).isEqualTo(original); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,badKey)).isZero(); + } + + /** 退回及换标的原选择字段保持互斥,不把提供者内部计划引用误写成公开来源字段。 */ + @Test void returnAndPartialRelabelChoicesKeepExclusiveSourceIdentity(){ + var state=empty(1);long container=state.containers().get(0).draftContainerId(); + var returned=new DispatchCommands.CargoSelection(SourceType.RETURN_RECEIPT,null,1L,null,1001L,"version-1"); + state=service.select(owner,new DraftSelection(state.recordVersion(),key(),state.draftToken(),container,returned)); + var relabel=new DispatchCommands.CargoSelection(SourceType.RELABEL,null,null,"RL-2",1002L,"version-2"); + state=service.select(owner,new DraftSelection(state.recordVersion(),key(),state.draftToken(),container,relabel)); + assertThat(state.sources()).extracting(Candidate::selection).containsExactly(returned,relabel); + assertThat(state.containers().get(0).sourceKeys()).containsExactly("RETURN:1","RELABEL:RL-2:1002"); + } + + /** 单来源请求一次加入同托完整两申请,客户与逻辑箱数独立但共享唯一物理占用。 */ + @Test void oneSelectionAtomicallyClaimsWholeRelabelGroupForMultipleCustomers(){ + relabelPair();var before=empty(1);String commandKey=key(); + var request=new DraftSelection(before.recordVersion(),commandKey,before.draftToken(),before.containers().get(0).draftContainerId(),choices.get(1L)); + var state=service.select(owner,request); + assertThat(state.recordVersion()).isEqualTo(before.recordVersion()+1); + assertThat(state.sources()).extracting(Candidate::customerId).containsExactly("101","102"); + assertThat(state.sources()).extracting(Candidate::sourceKey).containsExactly("RELABEL:RL-1:1001","RELABEL:RL-2:1001"); + assertThat(state.sources().stream().mapToLong(Candidate::availablePackages).sum()).isEqualTo(20); + assertThat(state.sources()).allSatisfy(candidate->assertThat(candidate.selectionGroup().sources()).hasSize(2)); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(DISTINCT physical_lock_id) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + assertThat(proxy().select(owner,request)).isEqualTo(state); + verify(sources).lockDraftSources("92",List.of(choices.get(1L),choices.get(2L))); + } + + /** 从组内任一成员解除时,同一命令结束全组逻辑来源并在最后成员后释放原物理占用。 */ + @Test void oneUnselectReleasesEveryFrozenMemberAndItsLastPhysicalClaim(){ + relabelPair();var state=select(empty(1),0,1);var before=state; + var request=new DraftSelection(state.recordVersion(),key(),state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(2L)); + versions.put(1L,"changed-after-freeze"); + var released=service.unselect(owner,request); + assertThat(released.recordVersion()).isEqualTo(before.recordVersion()+1); + assertThat(released.sources()).isEmpty();assertThat(released.containers().get(0).sourceKeys()).isEmpty(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='RELEASED' AND active_warehouse_pallet_id IS NULL",Integer.class)).isEqualTo(1); + assertThat(proxy().unselect(owner,request)).isEqualTo(released); + } + + /** 完整组缺少锁内成员或版本改变时不能留下部分来源、物理占用、命令结果或根版本。 */ + @Test void missingLockedMemberAndStaleGroupVersionRollBackAtomicSelection(){ + relabelPair();var state=empty(1);String failedKey=key(); + doReturn(List.of(whole(choices.get(1L)))).when(sources).lockDraftSources(eq("92"),anyList()); + assertThatThrownBy(()->service.select(owner,new DraftSelection(state.recordVersion(),failedKey,state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(1L)))) + .isInstanceOf(BizException.class); + assertThat(service.drafts(owner).get(0)).isEqualTo(state); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,failedKey)).isZero(); + versions.put(1L,"new-version"); + assertThatThrownBy(()->service.select(owner,new DraftSelection(state.recordVersion(),key(),state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(1L)))) + .isInstanceOf(BizException.class); + assertThat(service.drafts(owner).get(0)).isEqualTo(state); + } + + /** 完整组即使跨两个实托也不能布局拆开;整组移动同步逻辑成员及全部物理拥有者。 */ + @Test void wholeGroupCannotSplitButCanMoveAllPhysicalPalletsToAnotherContainer(){ + completeGroups.put(1L,List.of(1L,2L));completeGroups.put(2L,List.of(1L,2L)); + var state=select(empty(2),0,1);var original=state;var a=state.containers().get(0);var b=state.containers().get(1); + assertThatThrownBy(()->service.saveSnapshot(owner,new DraftSnapshot(original.recordVersion(),key(),original.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),1,15,List.of("PLAN:1")),new DraftContainer(b.draftContainerId(),2,15,List.of("PLAN:2")))))) + .isInstanceOf(BizException.class); + assertThat(service.drafts(owner).get(0)).isEqualTo(original); + state=service.saveSnapshot(owner,new DraftSnapshot(state.recordVersion(),key(),state.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),1,15,List.of()),new DraftContainer(b.draftContainerId(),2,15,a.sourceKeys())))); + assertThat(state.recordVersion()).isEqualTo(original.recordVersion()+1); + assertThat(db.queryForList("SELECT draft_container_id FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)) + .containsExactlyInAnyOrder(b.draftContainerId(),b.draftContainerId()); + assertThat(db.queryForList("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)) + .containsExactlyInAnyOrder(b.draftContainerId(),b.draftContainerId()); + } + + /** 一个原容器内独立完整组可以部分移走,原地实托及其客户来源不应被桥接带走。 */ + @Test void movingOneCompleteGroupKeepsOtherPhysicalPalletInOriginalContainer(){ + var state=select(empty(2),0,1);state=select(state,0,2);var a=state.containers().get(0);var b=state.containers().get(1); + state=service.saveSnapshot(owner,new DraftSnapshot(state.recordVersion(),key(),state.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),1,15,List.of("PLAN:2")),new DraftContainer(b.draftContainerId(),2,15,List.of("PLAN:1"))))); + assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE' AND warehouse_pallet_id=1001",Long.class)).isEqualTo(b.draftContainerId()); + assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE' AND warehouse_pallet_id=1002",Long.class)).isEqualTo(a.draftContainerId()); + assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_pallet_lock WHERE active_key='PLAN:2'",Long.class)).isEqualTo(a.draftContainerId()); + assertThat(state.sources()).hasSize(2); + } + + /** 还没有真实实托的计划独立计位,不制造物理占用记录。 */ + @Test void unmaterializedPlanKeepsLogicalIdentityWithoutSyntheticPhysicalClaim(){ + physical.remove(1L);var state=select(empty(1),0,1); + assertThat(state.sources()).hasSize(1);assertThat(state.sources().get(0).selection().warehousePalletId()).isNull(); + assertThat(state.sources().get(0).selectionGroup().pallets().get(0).warehousePalletId()).isNull(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key='PLAN:1' AND physical_lock_id IS NULL",Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Integer.class)).isZero(); + } + + /** 十六条逻辑来源共享一个实托仍只占一个容量位,箱数按每条真实贡献累计。 */ + @Test void capacityCountsOnePhysicalPalletAndAllLogicalPackages(){ + var members=java.util.stream.LongStream.rangeClosed(1,16).boxed().toList(); + for(var id:members){physical.put(id,1001L);completeGroups.put(id,members);} + var state=select(empty(1),0,1); + assertThat(state.containers().get(0).capacity()).isEqualTo(15); + assertThat(state.containers().get(0).sourceKeys()).hasSize(16); + assertThat(state.sources().stream().mapToLong(Candidate::availablePackages).sum()).isEqualTo(160); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(16); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + } + + /** 到期实托由共同提供者结束旧组成员后才能由新草稿完整领取,不复活原拥有者。 */ + @Test void expiryReclaimsWholePhysicalGroupWithoutTouchingNewOwner(){ + relabelPair();var started=Instant.parse("2026-10-19T03:00:00Z");var controlled=new MutableClock(started);clock=controlled;service=proxy(); + var original=select(empty(1),0,1);controlled.set(started.plus(Duration.ofMinutes(31))); + var next=service.createDraft(other,new CreateDraft(key(),"92"));next=service.createContainer(other,new CreateContainer(0L,key(),next.draftToken(),15)); + next=service.select(other,new DraftSelection(next.recordVersion(),key(),next.draftToken(),next.containers().get(0).draftContainerId(),choices.get(1L))); + assertThat(next.sources()).hasSize(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,original.draftToken())).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,original.draftToken())).isEqualTo(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE draft_token=? AND status='ACTIVE'",Integer.class,next.draftToken())).isEqualTo(1); + assertThatThrownBy(()->service.release(owner,new DraftRelease(original.recordVersion(),key(),original.draftToken()))).isInstanceOf(BizException.class); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(2); + } + + /** 纯释放或续约先在真实 Serial SQL 等待,接管事务可结束到期成员;旧拥有者不能复活租约或释放新拥有者。 */ + @ParameterizedTest + @ValueSource(strings={"UNSELECT","RELEASE","CREATE_CONTAINER","REMOVE_CONTAINER"}) + @Timeout(30) + void pureDraftActionsWaitAtSerialBeforeLogicalClaimsDuringExpiredTakeover(String action)throws Exception { + relabelPair();var started=Instant.parse("2026-10-19T03:00:00Z");var controlled=new MutableClock(started);clock=controlled;service=proxy(); + var draft=select(empty(2),0,1);var oldOwner=new DispatchPhysicalClaimService.Owner("92",null,draft.draftToken(), + draft.containers().get(0).draftContainerId(),owner.operatorId(),LocalDateTime.ofInstant(draft.expiresAt(),ZoneOffset.UTC)); + long oldPhysical=db.queryForObject("SELECT id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class); + controlled.set(started.plus(Duration.ofMinutes(30)).minusMillis(1)); + versions.put(1L,"changed-source-must-not-be-refreshed");clearInvocations(sources); + var holderReady=new CountDownLatch(1);var allowTakeover=new CountDownLatch(1);var waiterReady=new CountDownLatch(1); + var holderSession=new AtomicInteger();var waiterSession=new AtomicInteger();String failedKey="guard-crossing-"+action; + var pool=Executors.newFixedThreadPool(2); + try { + Future takeover=pool.submit(()->transactions.execute(status->{ + palletSerial.lock(List.of(1001L)); + physicalClaims.lockOwner(oldOwner); + holderSession.set(sessionId());holderReady.countDown();awaitLatch(allowTakeover); + var at=LocalDateTime.ofInstant(controlled.instant(),ZoneOffset.UTC); + var acquired=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner("92",9000L,null,null,"new-owner",null),List.of(1001L),at); + long physicalId=acquired.get(1001L);long id=9001; + for(var source:draft.sources())assertThat(actualClaimMapper.claim(id++,source.customerId(),source.sourceKey(), + source.selection().planPalletId(),source.selection().warehousePalletId(),source.selection().returnReceiptId(),physicalId,9000,"new-owner",at)).isEqualTo(1); + return physicalId; + })); + awaitLatch(holderReady); + Future pending=pool.submit(()->transactions.execute(status->{ + waiterSession.set(sessionId());waiterReady.countDown();return pureChange(action,draft,failedKey); + })); + awaitLatch(waiterReady); + awaitActualGuardWait(waiterSession.get(),holderSession.get(),pending); + controlled.set(started.plus(Duration.ofMinutes(30)).plusMillis(1));allowTakeover.countDown(); + long newPhysical=takeover.get(10,TimeUnit.SECONDS); + assertThatThrownBy(()->pending.get(10,TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class).hasCauseInstanceOf(BizException.class); + assertThat(newPhysical).isNotEqualTo(oldPhysical); + assertThat(db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE id=?",String.class,oldPhysical)).isEqualTo("EXPIRED"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,draft.draftToken())).isEqualTo(2); + assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=1001",Long.class)).isEqualTo(9000L); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE dispatch_order_id=9000 AND physical_lock_id=? AND status='ACTIVE'",Integer.class,newPhysical)).isEqualTo(2); + assertThat(db.queryForObject("SELECT record_version FROM ll_dispatch_draft WHERE draft_token=?",Long.class,draft.draftToken())).isEqualTo(draft.recordVersion()); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_container WHERE draft_token=?",Integer.class,draft.draftToken())).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,failedKey)).isZero(); + assertThatThrownBy(()->service.release(owner,new DraftRelease(draft.recordVersion(),"late-owner-release-"+action,draft.draftToken()))).isInstanceOf(BizException.class); + assertThat(db.queryForObject("SELECT id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=1001",Long.class)).isEqualTo(newPhysical); + verifyNoInteractions(sources); + } finally { + allowTakeover.countDown();pool.shutdownNow();assertThat(pool.awaitTermination(10,TimeUnit.SECONDS)).isTrue(); + } + } + + /** 共享实托完整组不能部分转换,成功转换和原键恢复保留全部客户及来源版本。 */ + @Test void conversionKeepsWholeSharedGroupAndOriginalCommandIdentity(){ + relabelPair();var state=select(empty(1),0,1);var full=conversion(state,"convert-whole");var vehicle=full.groups().get(0);var create=vehicle.dispatch(); + var partial=new DispatchCommands.Create("partial-child",create.warehouseId(),create.vehicle(),create.driver(),create.plannedDepartureAt(), + create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations()); + assertThatThrownBy(()->service.convert(owner,new ConvertDraft(state.recordVersion(),"partial-convert",state.draftToken(),List.of(new DraftVehicle(vehicle.draftContainerId(),partial))))) + .isInstanceOf(BizException.class); + assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero(); + var converted=service.convert(owner,full);assertThat(converted.vehicles()).hasSize(1); + var captured=org.mockito.ArgumentCaptor.forClass(DispatchCommands.Create.class);verify(aggregate).create(eq(owner),captured.capture()); + assertThat(captured.getValue().cargo()).containsExactlyElementsOf(state.sources().stream().map(Candidate::selection).toList()); + assertThat(db.queryForObject("SELECT cargo_count FROM draft_created_dispatch",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(1); + clearInvocations(aggregate,sources); + assertThat(proxy().convert(owner,full).vehicles()).isEqualTo(converted.vehicles());verifyNoInteractions(aggregate,sources); + } + + /** 第二辆车失败必须撤销首车、草稿状态、命令及所有占用释放;原键重试成功后只恢复结果。 */ + @Test void laterVehicleFailureRollsBackWholeBatchAndUnknownSuccessReplaysAfterRestart(){ + var state=empty(2);state=select(state,0,1);state=select(state,0,2);state=select(state,1,3); + var request=conversion(state,"convert");var original=state; + doAnswer(call->{DispatchCommands.Create r=call.getArgument(1);db.update("INSERT INTO draft_created_dispatch VALUES(?,?)","DS-"+r.idempotencyKey(),r.cargo().size()); + if(r.idempotencyKey().endsWith("-2"))throw new IllegalStateException("第二车创建失败");return result("DS-"+r.idempotencyKey(),r.idempotencyKey(),r.cargo().size());}).when(aggregate).create(any(),any()); + assertThatThrownBy(()->service.convert(owner,request)).isInstanceOf(IllegalStateException.class); + assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(3); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(3); + assertThat(service.drafts(owner).get(0)).isEqualTo(original); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key='convert'",Integer.class)).isZero(); + calls.clear();doAnswer(call->{calls.add("create");DispatchCommands.Create r=call.getArgument(1);String no="DS-"+r.idempotencyKey(); + db.update("INSERT INTO draft_created_dispatch VALUES(?,?)",no,r.cargo().size());return result(no,r.idempotencyKey(),r.cargo().size());}).when(aggregate).create(any(),any()); + var converted=service.convert(owner,request);assertThat(converted.vehicles()).hasSize(2);assertThat(converted.replayed()).isFalse(); + assertThat(calls).containsExactly("resources","sources","create","create"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch WHERE cargo_count=2",Integer.class)).isEqualTo(1); + assertThat(service.drafts(owner)).isEmpty();clearInvocations(aggregate,sources); + var replay=proxy().convert(owner,request);assertThat(replay.replayed()).isTrue();assertThat(replay.vehicles()).isEqualTo(converted.vehicles()); + verifyNoInteractions(aggregate,sources); + } + + /** 少组、跨仓、换版本或货物不完整不能开始创建;真实来源变旧也不能转换。 */ + @Test void conversionRejectsPartialGroupsForeignWarehouseAndStaleSource(){ + var state=select(empty(2),0,1);state=select(state,1,2);var original=conversion(state,"convert"); + assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"partial",original.draftToken(),original.groups().subList(0,1)))).isInstanceOf(BizException.class); + var first=original.groups().get(0);var r=first.dispatch(); + var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations()); + assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"foreign-wh",original.draftToken(),List.of(new DraftVehicle(first.draftContainerId(),foreign),original.groups().get(1))))).isInstanceOf(BizException.class); + clearInvocations(aggregate);versions.put(1L,"changed"); + assertThatThrownBy(()->service.convert(owner,original)).isInstanceOf(IllegalStateException.class); + verify(aggregate,never()).create(any(),any()); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(2); + } + + /** 同实托不同来源别名不能放到第二容器;非空删除和错容器解锁不影响原占用。 */ + @Test void physicalUniquenessWrongContainerAndReleaseStayOwnerScoped(){ + var state=select(empty(2),0,1);var original=state;physical.put(2L,1001L); + assertThatThrownBy(()->select(original,1,2)).isInstanceOf(BizException.class); + var first=state.containers().get(0);var second=state.containers().get(1);var source=state.sources().get(0).selection(); + assertThatThrownBy(()->service.unselect(owner,new DraftSelection(original.recordVersion(),key(),original.draftToken(),second.draftContainerId(),source))).isInstanceOf(BizException.class); + assertThatThrownBy(()->service.removeContainer(owner,original.draftToken(),first.draftContainerId(),new DispatchCommands.ReasonCommand(original.recordVersion(),key(),"not empty"))).isInstanceOf(BizException.class); + var release=new DraftRelease(state.recordVersion(),"release",state.draftToken());var released=service.release(owner,release); + assertThat(released.status()).isEqualTo("RELEASED");assertThat(released.sources()).isEmpty(); + assertThat(proxy().release(owner,release)).isEqualTo(released); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key IS NOT NULL",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id IS NOT NULL",Integer.class)).isZero(); + } + + /** 过期和旧版本不能变更;布局容量按完整物理来源集合核对。 */ + @Test void expiryVersionAndCapacityCannotBeBypassed(){ + var state=empty(2);for(long id=1;id<=15;id++)state=select(state,0,id);state=select(state,1,16);var original=state; + assertThatThrownBy(()->service.createContainer(owner,new CreateContainer(0L,key(),original.draftToken(),15))).isInstanceOf(BizException.class); + var all=state.sources().stream().map(Candidate::sourceKey).toList();var a=state.containers().get(0);var b=state.containers().get(1); + assertThatThrownBy(()->service.saveSnapshot(owner,new DraftSnapshot(original.recordVersion(),key(),original.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),1,15,all),new DraftContainer(b.draftContainerId(),2,15,List.of()))))).isInstanceOf(BizException.class); + db.update("UPDATE ll_dispatch_draft SET expires_at=? WHERE draft_token=?",LocalDateTime.ofInstant(Instant.now().minusSeconds(1),ZoneOffset.UTC),state.draftToken()); + assertThat(service.drafts(owner)).isEmpty(); + assertThatThrownBy(()->service.createContainer(owner,new CreateContainer(original.recordVersion(),key(),original.draftToken(),15))).isInstanceOf(BizException.class); + } + + /** 两个拥有者同时选同一实托,数据库唯一约束只允许一个草稿提交。 */ + @Test void concurrentDraftsCannotClaimSamePhysicalPallet()throws Exception { + var a=empty(1);var b=service.createDraft(other,new CreateDraft(key(),"92"));b=service.createContainer(other,new CreateContainer(0L,key(),b.draftToken(),15)); + var finalB=b;var start=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(2); + try {var one=pool.submit(()->attempt(owner,a,1,start));var two=pool.submit(()->attempt(other,finalB,1,start));start.countDown(); + assertThat(List.of(one.get(20,TimeUnit.SECONDS),two.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + }finally{pool.shutdownNow();} + } + + /** 同一原版本的两个写命令竞争根锁,只能提交一个公共版本增量。 */ + @Test void concurrentWritesCannotOverwriteTheSameDraftVersion()throws Exception { + var state=empty(1);var start=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(2); + try {var one=pool.submit(()->attempt(owner,state,1,start));var two=pool.submit(()->attempt(owner,state,2,start));start.countDown(); + assertThat(List.of(one.get(20,TimeUnit.SECONDS),two.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false); + var actual=service.drafts(owner).get(0);assertThat(actual.recordVersion()).isEqualTo(state.recordVersion()+1);assertThat(actual.sources()).hasSize(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + }finally{pool.shutdownNow();} + } + + /** 真实来源行锁等待跨过原到期时刻,首次选托不能返回无有效占用的成功结果。 */ + @Test void firstSelectionWaitingAcrossExpiryRollsBackSelectionClaimAndCommand()throws Exception { + var started=Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);var controlled=new MutableClock(started); + clock=controlled;service=proxy();var draft=empty(1);String selectKey="expired-select"; + controlled.set(started.plus(Duration.ofMinutes(30)).minusMillis(1)); + var entered=new CountDownLatch(1);beforeSourceLocks=entered::countDown;var pool=Executors.newSingleThreadExecutor(); + try(var holder=dataSource.getConnection()) { + holder.setAutoCommit(false); + try(var statement=holder.prepareStatement("SELECT id FROM draft_source_gate WHERE id=1 FOR UPDATE");var row=statement.executeQuery()){assertThat(row.next()).isTrue();} + var pending=pool.submit(()->service.select(owner,new DraftSelection(draft.recordVersion(),selectKey,draft.draftToken(),draft.containers().get(0).draftContainerId(),selection(1)))); + assertThat(entered.await(5,TimeUnit.SECONDS)).isTrue();assertThat(pending.isDone()).isFalse(); + controlled.set(started.plus(Duration.ofMinutes(30)).plusMillis(1));holder.commit(); + assertThatThrownBy(()->pending.get(15,TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class).hasCauseInstanceOf(BizException.class); + assertThat(db.queryForObject("SELECT sources_json FROM ll_dispatch_draft WHERE draft_token=?",String.class,draft.draftToken())).isEqualTo("[]"); + assertThat(db.queryForObject("SELECT source_keys_json FROM ll_dispatch_draft_container WHERE draft_token=?",String.class,draft.draftToken())).isEqualTo("[]"); + assertThat(db.queryForObject("SELECT record_version FROM ll_dispatch_draft WHERE draft_token=?",Long.class,draft.draftToken())).isEqualTo(draft.recordVersion()); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,selectKey)).isZero(); + var otherDraft=service.createDraft(other,new CreateDraft(key(),"92"));otherDraft=service.createContainer(other,new CreateContainer(0L,key(),otherDraft.draftToken(),15)); + var selected=service.select(other,new DraftSelection(otherDraft.recordVersion(),key(),otherDraft.draftToken(),otherDraft.containers().get(0).draftContainerId(),selection(1))); + assertThat(selected.sources()).hasSize(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND expires_at>?",Integer.class,LocalDateTime.ofInstant(controlled.instant(),ZoneOffset.UTC))).isEqualTo(1); + }finally{pool.shutdownNow();} + } + + /** 当前批次投影更新不推进草稿版本,原命令仍恢复其不可变结果。 */ + @Test void currentBatchProjectionRefreshesWithoutChangingDraftLifecycleOrCommandReplay() { + var batchVersion=new AtomicReference<>(0L); + when(appointmentProvider.candidateBatches(anyList())).thenAnswer(call->{ + List items=call.getArgument(0);var result=new LinkedHashMap(); + for(var item:items)result.put(item,new AppointmentWorkDispatchPort.CandidateBatches(List.of(new AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:9","AP-9",batchVersion.get(),item.customerId(),List.of(new AppointmentWorkDispatchPort.BatchSource("BOOKING:real","FBA:ONT8",10)))),List.of(),true)); + return result; + }); + var empty=empty(1);var request=new DraftSelection(empty.recordVersion(),"select-with-appointment",empty.draftToken(),empty.containers().get(0).draftContainerId(),selection(1)); + var original=service.select(owner,request);assertThat(original.sources().get(0).appointmentBatches().get(0).recordVersion()).isZero(); + batchVersion.set(7L);clearInvocations(appointmentProvider);var current=service.drafts(owner).get(0); + assertThat(current.recordVersion()).isEqualTo(original.recordVersion());assertThat(current.sources().get(0).appointmentBatches().get(0).recordVersion()).isEqualTo(7L); + assertThat(current.sources().get(0).appointmentBatches().get(0).sources().get(0).sourceKey()).isEqualTo("BOOKING:real");assertThat(current.sources().get(0).blockReasons()).isEmpty(); + verify(appointmentProvider).candidateBatches(List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",1L))); + assertThat(service.select(owner,request)).isEqualTo(original); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + } + + /** 线程通过实际事务代理提交,业务冲突只回滚自身草稿命令。 */ + private boolean attempt(Actor actor,Draft draft,long id,CountDownLatch start)throws InterruptedException { + start.await();try{service.select(actor,new DraftSelection(draft.recordVersion(),UUID.randomUUID().toString(),draft.draftToken(),draft.containers().get(0).draftContainerId(),selection(id)));return true;} + catch(BizException conflict){return false;} + } + /** 四种纯释放或租约入口只消费草稿原冻结来源,不请求当前来源资格。 */ + private Draft pureChange(String action,Draft draft,String commandKey) { + return switch(action) { + case "UNSELECT"->service.unselect(owner,new DraftSelection(draft.recordVersion(),commandKey,draft.draftToken(),draft.containers().get(0).draftContainerId(),draft.sources().get(0).selection())); + case "RELEASE"->service.release(owner,new DraftRelease(draft.recordVersion(),commandKey,draft.draftToken())); + case "CREATE_CONTAINER"->service.createContainer(owner,new CreateContainer(draft.recordVersion(),commandKey,draft.draftToken(),15)); + case "REMOVE_CONTAINER"->service.removeContainer(owner,draft.draftToken(),draft.containers().get(1).draftContainerId(),new DispatchCommands.ReasonCommand(draft.recordVersion(),commandKey,"移除空容器")); + default->throw new IllegalArgumentException("未支持的测试动作"); + }; + } + /** 数据库会话取自调用方当前事务连接,不以线程启动标记冒充 SQL 执行。 */ + private int sessionId(){return db.queryForObject("SELECT SESSION_ID()",Integer.class);} + /** 有界协调真实事务阶段,异常时由调用方 finally 放开持锁者。 */ + private void awaitLatch(CountDownLatch latch) { + try {if(!latch.await(10,TimeUnit.SECONDS))throw new IllegalStateException("等待真实事务阶段超时");} + catch(InterruptedException failure){Thread.currentThread().interrupt();throw new IllegalStateException("测试事务已中断",failure);} + } + /** 必须实际看到 guard 写语句被指定物理拥有者会话阻塞,不能用 Future 超时判断数据库已等待。 */ + private void awaitActualGuardWait(int waiter,int holder,Future pending)throws Exception { + assertThat(waiter).isPositive().isNotEqualTo(holder);assertThat(holder).isPositive(); + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(3);List> last=List.of(); + while(System.nanoTime()(); + for(long id:completeGroups.getOrDefault(seed,List.of(seed))) { + var input=choices.getOrDefault(id,id==seed?requested:selection(id)); + var choice=new DispatchCommands.CargoSelection(input.sourceType(),input.planPalletId(),input.returnReceiptId(),input.relabelNo(),physical.get(id),versions.get(id)); + String key=DispatchService.wholeSourceKey(choice); + members.add(new SelectionGroupSource(choice,key,id%2==0?"102":"101","客户"+id, + choice.sourceType()==SourceType.RELABEL?"RELABEL_ORDER":choice.sourceType().name(),choice.relabelNo()==null?"PLAN-"+id:choice.relabelNo(),10)); + } + var byPallet=new TreeMap>(); + for(var member:members)if(member.selection().warehousePalletId()!=null)byPallet.computeIfAbsent(member.selection().warehousePalletId(),ignored->new ArrayList<>()).add(member); + var pallets=new ArrayList<>(byPallet.entrySet().stream().map(entry->new SelectionGroupPallet(entry.getKey(),"P-"+entry.getKey(), + entry.getValue().stream().map(SelectionGroupSource::sourceKey).sorted().toList(),10L*entry.getValue().size())).toList()); + for(var member:members)if(member.selection().warehousePalletId()==null)pallets.add(new SelectionGroupPallet(null,null,List.of(member.sourceKey()),member.packages())); + return new SelectionGroup("GROUP-"+members.stream().map(SelectionGroupSource::sourceKey).sorted().toList(), + "GROUP-VERSION-"+members.stream().map(member->member.selection().sourceVersion()).toList(),List.copyOf(members),List.copyOf(pallets),true,List.of()); + } + /** 合成来源业务号用于测试身份定位,同实托时也不能借物理号猜成其他申请。 */ + private long sourceId(DispatchCommands.CargoSelection source) { + if(source.planPalletId()!=null)return source.planPalletId(); + if(source.returnReceiptId()!=null)return source.returnReceiptId(); + return Long.parseLong(source.relabelNo().substring("RL-".length())); + } + /** 每个非空服务器容器生成一组完整创建请求。 */ + private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null, + draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of())));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);} + /** 主单边界返回冻结的真实公开结果形状。 */ + private static CommandResult result(String no,String key,int cargo){return new CommandResult(no,Action.CREATE,key,0,Status.ACTIVE,Stage.BEFORE_LOADING,Instant.now(),Effect.APPLIED,new Counts(cargo,cargo,10L*cargo,0,0),new DispatchResults.Saved(no,List.of()),false,false);} + /** 测试独立命令键不依赖执行顺序或系统时间。 */ + private String key(){return "command-"+commands.incrementAndGet();} + + /** 测试时钟共享原子时刻,使等待线程与调用线程观察同一可控边界。 */ + private static final class MutableClock extends Clock { + private final AtomicReference current; + private final ZoneId zone; + /** 从确定UTC时刻开始,不依赖测试实际运行耗时。 */ + private MutableClock(Instant initial){this(new AtomicReference<>(initial),ZoneOffset.UTC);} + /** 时区视图共享同一时刻,不创建独立时间线。 */ + private MutableClock(AtomicReference current,ZoneId zone){this.current=current;this.zone=zone;} + /** 锁持有者控制时间越过原草稿到期点。 */ + private void set(Instant value){current.set(value);} + /** 返回当前视图时区。 */ + @Override public ZoneId getZone(){return zone;} + /** 保留共享时刻,满足Clock的时区视图语义。 */ + @Override public Clock withZone(ZoneId next){return new MutableClock(current,next);} + /** 所有线程读取同一个已发布时刻。 */ + @Override public Instant instant(){return current.get();} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java new file mode 100644 index 000000000..7d7ce6c3f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.fleet.driver.*; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.Optional; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 批量转换资源锁协议,不允许按组交错车辆与司机/占用。 */ +class DispatchDraftResourceLockTest { + @Test void existingScopeLocksEveryTripBeforeAnyVehicleDriverOrAssignment() { + var repository=mock(FleetTripRepository.class);var vehicles=mock(VehicleRepository.class); + var a=new FleetTripDispatch();a.setDispatchId(70L);a.setDispatchNo("DS-A");a.setTripId(7L);a.setTripNo("TR-7"); + var b=new FleetTripDispatch();b.setDispatchId(20L);b.setDispatchNo("DS-B");b.setTripId(2L);b.setTripNo("TR-2"); + var first=new FleetTripEntity();first.setId(7L);first.setTripNo("TR-7");first.setVehicleId(9L);first.setDriverId(8L); + var second=new FleetTripEntity();second.setId(2L);second.setTripNo("TR-2");second.setVehicleId(3L);second.setDriverId(4L); + when(repository.wholeBinding("DS-A")).thenReturn(a);when(repository.wholeBinding("DS-B")).thenReturn(b); + when(repository.lockTrip("TR-7")).thenReturn(first);when(repository.lockTrip("TR-2")).thenReturn(second); + when(repository.listBindings(7L)).thenReturn(List.of(a));when(repository.listBindings(2L)).thenReturn(List.of(b)); + var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class)); + service.lockExistingDispatchShells(List.of("DS-A","DS-B")); + var order=inOrder(repository,vehicles); + order.verify(repository).lockTrip("TR-2");order.verify(repository).lockTrip("TR-7"); + order.verify(repository).listBindings(2L);order.verify(repository).listBindings(7L); + order.verify(vehicles).lockById(3L);order.verify(vehicles).lockById(9L); + order.verify(repository).lockDriverIdentity(4L);order.verify(repository).lockDriverIdentity(8L); + order.verify(repository).lockResource("VEHICLE",3L);order.verify(repository).lockResource("VEHICLE",9L); + order.verify(repository).lockResource("DRIVER",4L);order.verify(repository).lockResource("DRIVER",8L); + } + /** 跨组逆序输入及重复引用仍只按全批身份全局顺序锁一次。 */ + @Test void locksAllVehiclesThenDriversThenAssignmentsInNumericOrder() { + var repository=mock(FleetTripRepository.class);var vehicles=mock(VehicleRepository.class); + when(vehicles.lockById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class))); + when(repository.lockDriverIdentity(anyLong())).thenAnswer(call->call.getArgument(0)); + var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class)); + var v9=new FleetExecutionModels.DispatchVehicle("MASTER",9L,null,null,null,null,null,List.of()); + var v3=new FleetExecutionModels.DispatchVehicle("MASTER",3L,null,null,null,null,null,List.of()); + var d8=new FleetExecutionModels.DispatchDriver("MASTER",8L,null,null,null,null,List.of(),null); + var d4=new FleetExecutionModels.DispatchDriver("MASTER",4L,null,null,null,null,List.of(),null); + service.lockDispatchCreationResources(List.of(v9,v3,v9),List.of(d8,d4,d8)); + var order=inOrder(vehicles,repository);order.verify(vehicles).lockById(3L);order.verify(vehicles).lockById(9L); + order.verify(repository).lockDriverIdentity(4L);order.verify(repository).lockDriverIdentity(8L); + order.verify(repository).lockResource("VEHICLE",3L);order.verify(repository).lockResource("VEHICLE",9L); + order.verify(repository).lockResource("DRIVER",4L);order.verify(repository).lockResource("DRIVER",8L); + verifyNoMoreInteractions(vehicles,repository); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java new file mode 100644 index 000000000..515f73550 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java @@ -0,0 +1,430 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchItemSource; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.Stock; +import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 受控来源 port 配合真实 Mapper、仓储、服务及事务;不代替真实换标 provider 的组合验收。 */ +class DispatchItemSourcePersistenceTest { + private static final long DISPATCH = 77L; + private static final long PALLET = 500L; + private static final LocalDateTime AT = LocalDateTime.of(2026, 10, 19, 12, 0); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final RelabelDispatchSourcePort provider = mock(RelabelDispatchSourcePort.class); + private JdbcTemplate jdbc; + private TransactionTemplate transaction; + private DispatchItemSourceRepository repository; + private DispatchItemSourceService service; + private DispatchAggregate root; + private DispatchItem first; + private DispatchItem second; + private DispatchItem third; + private List items; + private Snapshot supplied; + + /** 建立与正式贡献和拣货约束对应的隔离表,通过实际 Spring 代理加入调用方事务。 */ + @BeforeEach + void prepare() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:item-source-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute(""" + CREATE TABLE ll_dispatch_item_source ( + id BIGINT PRIMARY KEY, dispatch_order_id BIGINT NOT NULL, dispatch_item_id BIGINT NOT NULL, + customer_id VARCHAR(128) NOT NULL, relabel_application_id BIGINT NOT NULL, + relabel_item_id BIGINT NOT NULL, relabel_round_key VARCHAR(128) NOT NULL, warehouse_pallet_id BIGINT NOT NULL, + inventory_position_id BIGINT NOT NULL, inventory_balance_id BIGINT NOT NULL, + source_inventory_balance_id BIGINT, source_inbound_order_id BIGINT, + source_inbound_order_no VARCHAR(128), source_inbound_code VARCHAR(128), + notified_package_count BIGINT NOT NULL, package_count BIGINT NOT NULL, product_quantity BIGINT, + binding_basis_hash CHAR(64) NOT NULL, unit_facts_json CLOB NOT NULL, created_at TIMESTAMP NOT NULL, + UNIQUE(dispatch_item_id,relabel_item_id,inventory_position_id), + CHECK(notified_package_count>=0 AND package_count>0), CHECK(product_quantity IS NULL OR product_quantity>=0)) + """); + jdbc.execute(""" + CREATE TABLE ll_dispatch_pda_pick_order ( + id BIGINT PRIMARY KEY, customer_id VARCHAR(128) NOT NULL, pick_id BIGINT NOT NULL, + receipt_id BIGINT, return_receipt_id BIGINT, return_carton_id BIGINT, dispatch_item_source_id BIGINT, + order_id BIGINT NOT NULL, packages BIGINT NOT NULL, product_quantity BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + CHECK((CASE WHEN receipt_id IS NOT NULL THEN 1 ELSE 0 END) + +(CASE WHEN return_receipt_id IS NOT NULL THEN 1 ELSE 0 END) + +(CASE WHEN return_carton_id IS NOT NULL THEN 1 ELSE 0 END) + +(CASE WHEN dispatch_item_source_id IS NOT NULL THEN 1 ELSE 0 END)=1)) + """); + jdbc.execute("CREATE TABLE test_caller_transaction_marker(id INT PRIMARY KEY)"); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(DispatchItemSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchItemSourceMapper.class); + repository = new DispatchItemSourceRepository(mapper); + var manager = new DataSourceTransactionManager(ds); + transaction = new TransactionTemplate(manager); + var target = new DispatchItemSourceService(repository, provider, json); + var proxy = new ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource())); + service = (DispatchItemSourceService) proxy.getProxy(); + root = new DispatchAggregate(); + root.setId(DISPATCH); root.setOverseasWarehouseId("1001"); + first = item(11, "101", "RL-201", 1001, PALLET, 2); + second = item(12, "101", "RL-202", 1002, PALLET, 3); + third = item(13, "102", "RL-203", 1003, PALLET, 4); + items = List.of(first, second, third); + supplied = new Snapshot(List.of(application(201, "RL-201", "101", 1001), + application(202, "RL-202", "101", 1002), application(203, "RL-203", "102", 1003)), + List.of(source(201, "RL-201", 301, "101", PALLET, 101, 601, 501, 701, 2, 10L, "SHARED"), + source(202, "RL-202", 302, "101", PALLET, 101, 601, 502, 702, 3, null, "SHARED"), + source(203, "RL-203", 303, "102", PALLET, 102, 602, 503, 703, 4, 8L, "SHARED")), + List.of(position(101, "101", 601, PALLET, 5), position(102, "102", 602, PALLET, 4)), + "c".repeat(64), true, List.of()); + when(provider.lockedSnapshot(anyString(), anyList())).thenAnswer(invocation -> supplied); + } + + /** 同托两个申请和另一客户各存独立贡献,原入仓链及未知件数原样冻结。 */ + @Test + void captureFreezesEveryApplicationCustomerAndOriginalSource() throws Exception { + capture(); + var rows = repository.all(DISPATCH); + assertEquals(3, rows.size()); + assertEquals(List.of(201L, 202L, 203L), rows.stream().map(DispatchItemSource::getRelabelApplicationId).toList()); + assertEquals(List.of("101", "101", "102"), rows.stream().map(DispatchItemSource::getCustomerId).toList()); + assertEquals(List.of(701L, 702L, 703L), rows.stream().map(DispatchItemSource::getSourceInboundOrderId).toList()); + assertEquals(List.of(501L, 502L, 503L), rows.stream().map(DispatchItemSource::getSourceInventoryBalanceId).toList()); + assertEquals(List.of("INITIAL", "INITIAL", "INITIAL"), rows.stream().map(DispatchItemSource::getRelabelRoundKey).toList()); + assertEquals(List.of("IN-701", "IN-702", "IN-703"), rows.stream().map(DispatchItemSource::getSourceInboundOrderNo).toList()); + assertEquals(List.of("CODE-701", "CODE-702", "CODE-703"), rows.stream().map(DispatchItemSource::getSourceInboundCode).toList()); + assertNull(rows.get(1).getProductQuantity()); + assertEquals(10L, rows.get(0).getProductQuantity()); + assertEquals(5, rows.stream().filter(s -> s.getInventoryPositionId() == 101).mapToLong(DispatchItemSource::getPackageCount).sum()); + for (var row : rows) { + var original = supplied.sources().stream().filter(s -> s.itemId().equals(row.getRelabelItemId())).findFirst().orElseThrow(); + assertEquals(original.boundPackageCount(), row.getPackageCount().longValue()); + assertEquals(original.bindingBasisHash(), row.getBindingBasisHash()); + assertEquals(json.readTree(json.writeValueAsString(original.unitFacts())), json.readTree(row.getUnitFactsJson())); + assertNotEquals(supplied.applications().stream().filter(a -> a.applicationId().equals(original.applicationId())) + .findFirst().orElseThrow().fulfillmentOrderId(), row.getSourceInboundOrderId()); + } + verify(provider).lockedSnapshot("1001", List.of(PALLET)); + verify(provider, never()).snapshot(anyString(), anyList()); + verify(provider, never()).lockApplications(anyList()); + } + + /** 来源冻结和分配必须加入真实调用方事务,不能在事务外独立执行。 */ + @Test + void captureAndAllocateRequireCallerTransaction() { + assertThrows(IllegalTransactionStateException.class, () -> service.capture(root, items, items, AT)); + assertThrows(IllegalTransactionStateException.class, + () -> service.allocate(DISPATCH, first, 101, 1, 0, null, List.of(stock(101, 601, 5)))); + assertEquals(0, countSources()); + } + + /** 同实托遗漏另一申请时,已尝试保存的首申请和调用方其他写入必须一起回滚。 */ + @Test + void missingApplicationOnPhysicalPalletRollsBackWholeCallerTransaction() { + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> { + jdbc.update("INSERT INTO test_caller_transaction_marker VALUES(1)"); + service.capture(root, List.of(first, third), List.of(first, third), AT); + })); + assertEquals(0, countSources()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM test_caller_transaction_marker", Integer.class)); + } + + /** 同申请还有另一实托时,不能只冻结请求托盘上的部分明细。 */ + @Test + void missingOtherPalletOfSameApplicationRollsBackAllContributions() { + var sources = new ArrayList<>(supplied.sources()); + sources.add(source(201, "RL-201", 304, "101", 501, 103, 603, 504, 704, 1, null, "OTHER-PALLET")); + var positions = new ArrayList<>(supplied.positions()); + positions.add(position(103, "101", 603, 501, 1)); + supplied = snapshot(sources, positions); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + } + + /** 每张申请各自小于 HOLD 仍不能掩盖同位置合计超量。 */ + @Test + void combinedApplicationsCannotExceedPositionHold() { + supplied = snapshot(supplied.sources(), List.of(position(101, "101", 601, PALLET, 4), + position(102, "102", 602, PALLET, 4))); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + } + + /** 额外 HOLD 也说明实托成员未完整核齐,不能悄悄忽略未归属冻结箱数。 */ + @Test + void unexplainedHoldCannotBeOmittedFromCompletePhysicalSelection() { + supplied = snapshot(supplied.sources(), List.of(position(101, "101", 601, PALLET, 6), + position(102, "102", 602, PALLET, 4))); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + } + + /** 客户与履约订单必须匹配真实申请,不接受借原入仓订单替换履约归属。 */ + @Test + void captureRejectsWrongCustomerAndWrongFulfillmentOwner() { + second.setCustomerId("102"); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + second.setCustomerId("101"); + first.setOrderId(701L); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + } + + /** 相同贡献唯一约束阻止重复冻结,失败事务不能覆盖或删除已提交来源。 */ + @Test + void duplicateCaptureLeavesOriginalImmutableRowsIntact() { + capture(); + var original = repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList(); + assertThrows(DataIntegrityViolationException.class, + () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT.plusSeconds(1)))); + assertEquals(original, repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList()); + assertEquals(3, countSources()); + } + + /** 不新增明细时仍复核完整成员,但不能重写已冻结的原货源。 */ + @Test + void recheckingExistingMembersDoesNotAppendOrReplaceSources() { + capture(); + var original = repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList(); + transaction.executeWithoutResult(status -> service.capture(root, items, List.of(), AT.plusSeconds(1))); + assertEquals(original, repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList()); + } + + /** 原轮次写入真实持久列后允许正常扫码状态,返工新轮次不能复用旧冻结来源。 */ + @Test + void frozenRoundSurvivesNormalStateChangesAndRejectsReworkRound() { + capture(); + var scheduled = supplied.applications().stream().map(app -> new Application(app.applicationId(), app.relabelNo(), + app.customerId(), app.fulfillmentOrderId(), "READY_FOR_OUTBOUND", "PRE_DISPATCH_CREATED", "TRUCK", + app.roundKey(), AT.plusSeconds(1), app.customerConfirmedAt())).toList(); + var current = new Snapshot(scheduled, supplied.sources(), supplied.positions(), supplied.compositionVersion(), true, List.of()); + transaction.executeWithoutResult(status -> service.validateFrozen(root, items, current)); + var reworked = scheduled.stream().map(app -> new Application(app.applicationId(), app.relabelNo(), app.customerId(), + app.fulfillmentOrderId(), app.status(), app.fulfillmentStatus(), app.deliveryMethod(), + app.applicationId() == 201 ? "2026-10-20T12:00" : app.roundKey(), AT.plusDays(1), app.customerConfirmedAt())).toList(); + var changed = new Snapshot(reworked, supplied.sources(), supplied.positions(), supplied.compositionVersion(), true, List.of()); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.validateFrozen(root, items, changed))); + assertEquals(List.of("INITIAL", "INITIAL", "INITIAL"), jdbc.queryForList( + "SELECT relabel_round_key FROM ll_dispatch_item_source ORDER BY dispatch_item_id", String.class)); + } + + /** 同箱唛跨申请及客户仍按本逻辑明细精确归属,不把整贡献件数复制给本次部分扫描。 */ + @Test + void sameCartonMarkAllocatesOnlySelectedApplicationAndCustomer() { + capture(); + var known = allocate(first, 101, 1, 3, "SHARED", 5); + assertEquals(1, known.size()); assertEquals(row(11).getId(), known.get(0).dispatchItemSourceId()); + assertEquals(1, known.get(0).packages()); assertEquals(3L, known.get(0).productQuantity()); + var unknown = allocate(second, 101, 1, 0, "SHARED", 5); + assertEquals(row(12).getId(), unknown.get(0).dispatchItemSourceId()); + assertNull(unknown.get(0).productQuantity()); + var otherCustomer = allocate(third, 102, 1, 1, "SHARED", 4); + assertEquals(row(13).getId(), otherCustomer.get(0).dispatchItemSourceId()); + assertEquals(1L, otherCustomer.get(0).productQuantity()); + } + + /** 分配只减本来源真实已消费额度,耗尽后不能向同位置另一申请 FIFO 借箱。 */ + @Test + void consumedAllowanceCannotBorrowFromAnotherApplicationOnSamePosition() { + capture(); + var firstPart = allocate(first, 101, 1, 3, "SHARED", 5).get(0); + pick(1, "101", 1001, firstPart); + assertThrows(BizException.class, () -> allocate(first, 101, 2, 7, "SHARED", 4)); + var lastPart = allocate(first, 101, 1, 7, "SHARED", 4).get(0); + assertEquals(firstPart.dispatchItemSourceId(), lastPart.dispatchItemSourceId()); + assertEquals(7L, lastPart.productQuantity()); + pick(2, "101", 1001, lastPart); + assertThrows(BizException.class, () -> allocate(first, 101, 1, 0, "SHARED", 3)); + var separate = allocate(second, 101, 3, 0, "SHARED", 3).get(0); + assertEquals(row(12).getId(), separate.dispatchItemSourceId()); + assertEquals(3, separate.packages()); assertNull(separate.productQuantity()); + assertEquals(2, repository.picked("101", row(11).getId())); + assertEquals(0, repository.picked("101", row(12).getId())); + } + + /** 排车、客户、逻辑明细和实际位置任一不符都不能消费已有真实贡献。 */ + @Test + void allocateRejectsWrongDispatchCustomerItemAndPosition() { + capture(); + assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH + 1, first, + 101, 1, 0, "SHARED", List.of(stock(101, 601, 5))))); + var wrongCustomer = item(11, "102", "RL-201", 1001, PALLET, 2); + assertThrows(BizException.class, () -> allocate(wrongCustomer, 102, 1, 0, "SHARED", 4)); + var wrongItem = item(999, "101", "RL-201", 1001, PALLET, 2); + assertThrows(BizException.class, () -> allocate(wrongItem, 101, 1, 0, "SHARED", 5)); + assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first, + 102, 1, 0, "SHARED", List.of(stock(101, 601, 5))))); + assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first, + 101, 1, 0, "SHARED", List.of()))); + } + + /** 聚合库存可用数、其他申请额度及变更后的余额都不能补足本来源超量扫描。 */ + @Test + void allocateRejectsExcessPackagesWrongBalanceAndChangedHold() { + capture(); + assertThrows(BizException.class, () -> allocate(first, 101, 3, 10, null, 5)); + assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first, + 101, 1, 3, null, List.of(stock(101, 999, 5))))); + assertThrows(BizException.class, () -> allocate(first, 101, 1, 3, null, 6)); + assertThrows(BizException.class, () -> allocate(first, 101, 2, 9, null, 5)); + assertThrows(BizException.class, () -> allocate(first, 101, 1, 11, null, 5)); + } + + /** 同申请同位置有多明细时,部分箱数必须有明确箱唛,不能按记录顺序拆分。 */ + @Test + void partialMultiContributionPickRequiresExactCartonMark() { + addAnotherItemInFirstApplication(); + capture(); + assertThrows(BizException.class, () -> allocate(first, 101, 1, 0, null, 6)); + var extra = allocate(first, 101, 1, 0, "EXTRA", 6); + assertEquals(1, extra.size()); assertNull(extra.get(0).productQuantity()); + assertEquals(304L, repository.find(extra.get(0).dispatchItemSourceId()).getRelabelItemId()); + var original = allocate(first, 101, 1, 3, "SHARED", 6); + assertEquals(301L, repository.find(original.get(0).dispatchItemSourceId()).getRelabelItemId()); + assertEquals(3L, original.get(0).productQuantity()); + } + + /** 全量多明细扫描分别保留已知件数与未知空值,不复制整托或整个贡献的件数。 */ + @Test + void fullMultiContributionPickKeepsKnownAndUnknownPieceFactsSeparate() { + addAnotherItemInFirstApplication(); + capture(); + var allocated = allocate(first, 101, 3, 10, null, 6); + assertEquals(2, allocated.size()); + assertEquals(3, allocated.stream().mapToLong(DispatchItemSourceService.Allocation::packages).sum()); + assertEquals(10, allocated.stream().filter(a -> a.productQuantity() != null).mapToLong(a -> a.productQuantity()).sum()); + assertEquals(1, allocated.stream().filter(a -> a.productQuantity() == null).count()); + assertThrows(BizException.class, () -> allocate(first, 101, 3, 20, null, 6)); + } + + /** SQL 只能删除未被真实拣货引用的来源,不能连带删除同实托已消费申请及其他客户。 */ + @Test + void removeUnpickedCannotDeleteAnySourceWithActualPick() { + capture(); + var kept = row(11).getId(); + pick(1, "101", 1001, allocate(first, 101, 1, 3, "SHARED", 5).get(0)); + transaction.executeWithoutResult(status -> { + service.removeUnpicked(DISPATCH, 11); + service.removeUnpicked(DISPATCH, 12); + service.removeUnpicked(DISPATCH + 1, 13); + }); + assertNotNull(repository.find(kept)); + assertEquals(List.of(11L, 13L), repository.all(DISPATCH).stream().map(DispatchItemSource::getDispatchItemId).toList()); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick_order WHERE dispatch_item_source_id=?", Integer.class, kept)); + assertEquals(2, countSources()); + } + + /** 创建本次完整受控来源输入,不暗示真实 provider 已参与本组件测试。 */ + private Snapshot snapshot(List sources, List positions) { + return new Snapshot(supplied.applications(), List.copyOf(sources), List.copyOf(positions), supplied.compositionVersion(), true, List.of()); + } + + /** 在第一申请同位置增加一条未知件数明细,用于验证多贡献精确分配。 */ + private void addAnotherItemInFirstApplication() { + first.setPackageCount(3); + var sources = new ArrayList<>(supplied.sources()); + sources.add(source(201, "RL-201", 304, "101", PALLET, 101, 601, 504, 704, 1, null, "EXTRA")); + supplied = snapshot(sources, List.of(position(101, "101", 601, PALLET, 6), position(102, "102", 602, PALLET, 4))); + } + + /** 真实服务事务只冻结受控输入,数据写入仍经过实际仓储和 Mapper。 */ + private void capture() { + transaction.executeWithoutResult(status -> service.capture(root, items, items, AT)); + } + + /** 本次输入件数明确传入,库存数量取调用方锁后的受控真实位置快照。 */ + private List allocate(DispatchItem item, long position, long packages, + long quantity, String mark, long hold) { + long balance = position == 102 ? 602 : 601; + return transaction.execute(status -> service.allocate(DISPATCH, item, position, packages, quantity, mark, + List.of(stock(position, balance, hold)))); + } + + /** 模拟已提交的不可变真实拣货账本供实际 Mapper 读取消费额度,不冒充 PDA 全链。 */ + private void pick(long id, String customer, long fulfillment, DispatchItemSourceService.Allocation allocation) { + jdbc.update(""" + INSERT INTO ll_dispatch_pda_pick_order + (id,customer_id,pick_id,dispatch_item_source_id,order_id,packages,product_quantity,created_at) + VALUES(?,?,?,?,?,?,?,?) + """, id, customer, 9000 + id, allocation.dispatchItemSourceId(), fulfillment, allocation.packages(), + allocation.productQuantity() == null ? 0 : allocation.productQuantity(), AT.plusSeconds(id)); + } + + /** 读取一条逻辑明细的原冻结来源。 */ + private DispatchItemSource row(long itemId) { + return repository.all(DISPATCH).stream().filter(s -> s.getDispatchItemId() == itemId).findFirst().orElseThrow(); + } + + /** 从真实数据库核对事务提交后的贡献数量。 */ + private int countSources() { + return jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item_source", Integer.class); + } + + /** 构造本次逻辑运输来源,真实履约订单独立于原入仓链。 */ + private DispatchItem item(long id, String customer, String no, long order, long pallet, int packages) { + var item = new DispatchItem(); + item.setId(id); item.setDispatchOrderId(DISPATCH); item.setCustomerId(customer); item.setOrderId(order); + item.setOverseasWarehouseId("1001"); item.setWarehousePalletId(pallet); item.setPackageCount(packages); + item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(no); item.setSourceStage("RELABEL"); + return item; + } + + /** 构造已确认的普通卡车换标申请事实。 */ + private Application application(long id, String no, String customer, long fulfillment) { + return new Application(id, no, customer, fulfillment, "READY_FOR_OUTBOUND", "WAIT_PRE_DISPATCH", "TRUCK", "INITIAL", AT, AT); + } + + /** 构造独立冻结货源与箱级作业事实,通知和成功均不能替代实际绑定贡献。 */ + private BoundSource source(long app, String no, long item, String customer, long pallet, long position, + long balance, long sourceBalance, long inbound, long packages, Long pieces, String mark) { + return new BoundSource(app, no, item, customer, "1001", pallet, position, balance, sourceBalance, inbound, + "IN-" + inbound, "CODE-" + inbound, packages, packages, pieces, "b".repeat(64), + List.of(new UnitFact(item, mark, "NEW-" + mark, "RELABELLED", 1), + new UnitFact(item + 1000, "EXCEPTION-" + item, null, "EXCEPTION", 1))); + } + + /** 同一实托可以含不同客户位置,每个位置的 HOLD 独立守恒。 */ + private Position position(long id, String customer, long balance, long pallet, long hold) { + return new Position(id, customer, balance, pallet, 0, hold, 0, 0, 0, 1); + } + + /** 保留大额 AVAILABLE 以证明它不能补足本来源的冻结额度。 */ + private Stock stock(long position, long balance, long hold) { + return new Stock(position, balance, PALLET, "1001", 1000, hold, 0, 0, 1); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java new file mode 100644 index 000000000..d4c32ae67 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -0,0 +1,221 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.mapper.*; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.relabel.mapper.*; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.baiyue.lastleg.relabel.service.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */ +class DispatchMergedRelabelPersistenceTest { + /** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */ + @ParameterizedTest @ValueSource(booleans={false,true}) + void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception { + var f=new Merged(crossCustomer);var group=f.group(); + assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();assertThat(group.sources()).hasSize(2);assertThat(group.pallets()).hasSize(1); + var created=f.create("merged",group.sources().stream().map(s->s.selection()).toList()); + assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().itemCount()).isEqualTo(2);assertThat(created.counts().packageCount()).isEqualTo(5); + assertThat(f.count("ll_dispatch_physical_pallet_lock")).isEqualTo(1);assertThat(f.count("ll_dispatch_pallet_lock")).isEqualTo(2); + assertThat(f.f.db.queryForObject("SELECT COUNT(DISTINCT physical_lock_id) FROM ll_dispatch_pallet_lock",Long.class)).isEqualTo(1); + var approved=f.f.approve(created);var before=f.pda.task(f.picker,created.dispatchNo()); + assertThat(before.items()).extracting(i->i.positions().stream().mapToLong(p->p.availablePackages()).sum()).containsExactlyInAnyOrder(2L,3L); + var first=f.item("RL-A");var second=f.item("RL-B"); + var request=new DispatchCommands.Pick(approved.recordVersion(),"pick-A",101L,2L,0L,null,null); + var picked=f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),first,request)); + assertThat(f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),first,request)).replayed()).isTrue(); + assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(1); + var halfway=f.pda.task(f.picker,created.dispatchNo()); + assertThat(halfway.items().stream().filter(i->i.sourceBusinessNo().equals("RL-B")).findFirst().orElseThrow().positions().get(0).availablePackages()).isEqualTo(3); + var last=f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),second,new DispatchCommands.Pick(picked.recordVersion(),"pick-B",crossCustomer?102L:101L,3L,0L,null,null))); + assertThat(f.pda.task(f.picker,created.dispatchNo()).pickingReconciled()).isTrue(); + assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2); + assertThat(f.f.db.queryForObject("SELECT COALESCE(SUM(hold_packages+available_packages+allocated_packages+picked_packages),0) FROM ll_inventory_position",Long.class)).isZero(); + assertThat(f.count("ll_dispatch_pda_pick_order WHERE dispatch_item_source_id IS NOT NULL AND receipt_id IS NULL AND return_receipt_id IS NULL AND return_carton_id IS NULL")).isEqualTo(2); + var context=f.f.service.preview(f.f.actor,created.dispatchNo());assertThat(context.cargo()).hasSize(2); + assertThat(context.cargo()).extracting(c->c.sourceOrigin().sourceInboundOrderNo()).containsExactlyInAnyOrder("ORIGIN-A","ORIGIN-B"); + assertThat(context.cargo()).extracting(c->c.orderId()).containsExactlyInAnyOrder(9001L,9002L); + var preview=f.f.service.loading(f.f.actor,created.dispatchNo());assertThat(preview.pallets()).hasSize(1);assertThat(preview.pallets().get(0).scheduledPackages()).isEqualTo(5); + var pallet=preview.pallets().get(0); + var loaded=f.f.tx.execute(s->f.f.service.loadingComplete(f.picker,created.dispatchNo(),new DispatchCommands.LoadingComplete(last.recordVersion(),"load",preview.manifestHash(), + List.of(new DispatchContracts.LoadingMeasurement(501L,1,new BigDecimal("10"),BigDecimal.ONE,pallet.deliveryIndex()==null?1:pallet.deliveryIndex())),null))); + assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING); + assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2); + } + + /** 缺一个申请、同量换原货源、轮次或逐件事实变化,都不能用旧选择冻结新事实。 */ + @Test void incompleteOrChangedSelectionRollsBackEveryClaimAndRoot() throws Exception { + var f=new Merged(false);var selections=f.group().sources().stream().map(s->s.selection()).toList(); + assertThatThrownBy(()->f.create("partial",List.of(selections.get(0)))).hasMessageContaining("完整选择"); + assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero(); + f.f.db.update("UPDATE ll_relabel_item SET source_inbound_order_no='CHANGED' WHERE id=302"); + assertThatThrownBy(()->f.create("stale",selections)).hasMessageContaining("版本已变化"); + assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_item_source")).isZero(); + } + + /** 建单后轮次或原货源同量变化仍失败,前面任务/库存命令/归属/版本均不能留下。 */ + @ParameterizedTest @ValueSource(strings={"origin","round","fact"}) + void changedFrozenOriginPreventsPickingBeforeAnyInventoryWrite(String changed) throws Exception { + var f=new Merged(false);var created=f.create("origin-drift",f.group().sources().stream().map(s->s.selection()).toList());var approved=f.f.approve(created); + if("origin".equals(changed))f.f.db.update("UPDATE ll_relabel_item SET source_inbound_code='OTHER' WHERE id=302"); + else if("round".equals(changed))f.f.db.update("UPDATE ll_relabel_pda_history SET detail_json=? WHERE application_id=202 AND action='COMPLETE'",f.f.json.writeValueAsString(Map.of("roundKey","OTHER"))); + else f.f.db.update("UPDATE ll_relabel_pda_unit_fact SET new_label='OTHER' WHERE application_id=202"); + assertThatThrownBy(()->f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),new DispatchCommands.Pick(approved.recordVersion(),"bad-origin",101L,2L,0L,null,null)))) + .hasMessageContaining("原货源依据已变化"); + assertThat(f.count("ll_inventory_operation")).isZero();assertThat(f.count("ll_dispatch_pda_pick")).isZero(); + assertThat(f.f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion()); + } + + /** 第二个申请归属失败时真实四个库存动作与首层任务一起回滚。 */ + @Test void lateAttributionFailureRollsBackInventoryAndAllowsSameCommandRetry() throws Exception { + var f=new Merged(false);var created=f.create("rollback",f.group().sources().stream().map(s->s.selection()).toList());var approved=f.f.approve(created); + var attribution=spy(f.attribution);doThrow(new IllegalStateException("归属故障")).when(attribution).attributeRelabel(anyString(),any(),anyLong(),anyList()); + ReflectionTestUtils.setField(f.picking,"attribution",attribution); + var request=new DispatchCommands.Pick(approved.recordVersion(),"retry",101L,2L,0L,null,null); + assertThatThrownBy(()->f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),request))).hasMessageContaining("归属故障"); + assertThat(f.count("ll_inventory_operation")).isZero();assertThat(f.count("ll_inventory_ledger")).isZero();assertThat(f.count("ll_dispatch_pda_task")).isZero(); + ReflectionTestUtils.setField(f.picking,"attribution",f.attribution); + assertThat(f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),request)).recordVersion()).isEqualTo(approved.recordVersion()+1); + } + + /** 取消按整车释放两份逻辑成员和一个物理拥有者,原业务号恢复不误释放新单。 */ + @Test void zeroScanCancellationRestoresBothApplicationsAndReleasesOnePallet() throws Exception { + var f=new Merged(false);var created=f.create("cancel",f.group().sources().stream().map(s->s.selection()).toList()); + var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-merged","重排"); + f.f.tx.execute(s->f.f.service.cancel(f.f.actor,created.dispatchNo(),request)); + assertThat(f.count("ll_dispatch_pallet_lock WHERE status='ACTIVE'")).isZero();assertThat(f.count("ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'")).isZero(); + assertThat(f.count("ll_relabel_application WHERE fulfillment_status='WAIT_PRE_DISPATCH'")).isEqualTo(2); + var next=f.create("next",f.group().sources().stream().map(s->s.selection()).toList()); + assertThat(f.f.tx.execute(s->f.f.service.cancel(f.f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThat(f.f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(f.f.repository.find(next.dispatchNo()).getId()); + } + + /** 两条真实建单事务竞争同一完整组,只能提交一车、两逻辑来源及一个物理拥有者。 */ + @Test void concurrentCreatesCannotSplitOrDoubleClaimTheMergedPallet() throws Exception { + var f=new Merged(false);var selections=f.group().sources().stream().map(s->s.selection()).toList(); + var pool=java.util.concurrent.Executors.newFixedThreadPool(2);var start=new java.util.concurrent.CountDownLatch(1); + try { + var results=new ArrayList>(); + for(int n=0;n<2;n++){final int own=n;results.add(pool.submit(()->{start.await();try{f.create("race-"+own,selections);return true;}catch(org.springframework.web.server.ResponseStatusException|com.baiyue.common.core.exception.BizException conflict){return false;}}));} + start.countDown();int applied=0;for(var result:results)if(result.get(20,java.util.concurrent.TimeUnit.SECONDS))applied++; + assertThat(applied).isEqualTo(1);assertThat(f.count("ll_dispatch_order")).isEqualTo(1);assertThat(f.count("ll_dispatch_item")).isEqualTo(2); + assertThat(f.count("ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'")).isEqualTo(1);assertThat(f.count("ll_dispatch_pallet_lock WHERE status='ACTIVE'")).isEqualTo(2); + } finally {pool.shutdownNow();assertThat(pool.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();} + } + + /** H2方言适配仍保留四类来源恰好一个,缺凭据或伪造双凭据必须由数据库拒绝。 */ + @Test void actualAttributionConstraintRejectsMissingAndAmbiguousSources() throws Exception { + var f=new Merged(false); + assertThatThrownBy(()->f.insert("ll_dispatch_pda_pick_order","id",1,"customer_id","101","pick_id",1,"order_id",9001,"packages",1)).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThatThrownBy(()->f.insert("ll_dispatch_pda_pick_order","id",2,"customer_id","101","pick_id",1,"order_id",9001,"packages",1,"receipt_id",1,"dispatch_item_source_id",1)).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(f.count("ll_dispatch_pda_pick_order")).isZero(); + } + + /** 正式初始化的计划实托绑定参与真实候选SQL,同码另一仓位置不能混入。 */ + @Test void actualPlanSchemaUsesRealPhysicalBindingAndRejectsAnotherWarehouse() throws Exception { + var f=new Merged(false); + f.insert("ll_order","id",9100,"order_no","PLAN-ORDER","customer_id","101","source_region","OVERSEAS","source_inbound_id","PLAN-SOURCE","source_event_id","PLAN-SOURCE","source_loading_plan_id","PLAN-SOURCE","source_payload_hash","a".repeat(64)); + f.insert("ll_inbound_plan","id",9011,"customer_id","101","source_order_id",9100,"container_no","PLAN-CN","plan_no","PLAN-NO","overseas_warehouse_id",9,"creation_mode","WAREHOUSE_TRANSFER"); + f.insert("ll_inbound_plan_line","id",9010,"customer_id","101","plan_id",9011,"order_id",9100,"inbound_code","PLAN-IN","destination_type","FBA","fba_warehouse_code","ONT8","volume_cbm",BigDecimal.ONE); + f.insert("ll_inbound_plan_pallet","id",9012,"customer_id","101","plan_id",9011,"pallet_no","PLAN-PALLET","warehouse_pallet_id",502,"destination_type","FBA","destination_warehouse_code","ONT8","sequence_no",1,"allocated_volume_cbm",BigDecimal.ONE); + f.insert("ll_inbound_plan_pallet_allocation","id",9013,"customer_id","101","plan_id",9011,"line_id",9010,"pallet_id",9012,"allocated_volume_cbm",BigDecimal.ONE,"planned_package_count",4); + f.insert("ll_inventory_balance","id",603,"customer_id","101","customer_name","测试","overseas_warehouse_id","9","inbound_order_id",7002,"inbound_code","PLAN-IN","sku_code","SKU","available_packages",4); + f.insert("ll_inventory_position","id",103,"customer_id","101","balance_id",603,"overseas_warehouse_id","9","pallet_id",502,"pallet_no","PLAN-PALLET","warehouse_location_code","LOC-2","available_packages",4); + // 相同码在另一仓另一个physical,不能成为父计划的来源。 + f.insert("ll_inventory_position","id",104,"customer_id","101","balance_id",603,"overseas_warehouse_id","99","pallet_id",999,"pallet_no","PLAN-PALLET","warehouse_location_code","LOC-OTHER","available_packages",8); + var group=f.rules.selectionGroup("9",new DispatchCommands.CargoSelection(SourceType.INBOUND_PLAN,9012L,null,null,null,null)); + assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();assertThat(group.sources()).hasSize(1);assertThat(group.pallets()).hasSize(1); + assertThat(group.pallets().get(0).warehousePalletId()).isEqualTo(502L);assertThat(group.sources().get(0).packages()).isEqualTo(4); + var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4); + } + + /** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ + private static final class Merged { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchService rules=new DispatchService();final DispatchPickingService picking;final DispatchPdaQueryService pda; + final DispatchPickOrderAttributionService attribution=new DispatchPickOrderAttributionService(); + final DispatchContracts.Actor picker=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + Merged(boolean crossCustomer) throws Exception { + var configuration=f.session.getConfiguration(); + for(var mapper:List.of(RelabelApplicationMapper.class,RelabelDispatchSourceMapper.class,PhysicalPalletSerialMapper.class,DispatchWholePdaReadMapper.class, + DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class, + InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryOperationMapper.class,InventoryCommandMapper.class,ReturnStockReceiptMapper.class))configuration.addMapper(mapper); + var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", + "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", + "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", + "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { + f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); + if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table); + // H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。 + f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","") + .replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2") + .replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)")); + } + var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json); + var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json); + var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); + ReflectionTestUtils.setField(rules,"relabelSources",provider);ReflectionTestUtils.setField(rules,"palletSerial",new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(f.session.getMapper(PhysicalPalletSerialMapper.class)))); + ReflectionTestUtils.setField(rules,"candidates",f.session.getMapper(DispatchCandidateMapper.class));ReflectionTestUtils.setField(rules,"relabelApplications",f.session.getMapper(RelabelApplicationMapper.class)); + ReflectionTestUtils.setField(rules,"relabelMeasurements",mock(RelabelPalletMeasurementService.class));ReflectionTestUtils.setField(rules,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(rules,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class)); + ReflectionTestUtils.setField(f.service,"sourceRules",rules);ReflectionTestUtils.setField(f.service,"relabelSources",snapshots); + var inventory=new InventoryCommandService(); + ReflectionTestUtils.setField(inventory,"positions",f.session.getMapper(InventoryPositionMapper.class));ReflectionTestUtils.setField(inventory,"balances",f.session.getMapper(InventoryBalanceMapper.class)); + ReflectionTestUtils.setField(inventory,"ledgers",f.session.getMapper(InventoryLedgerMapper.class));ReflectionTestUtils.setField(inventory,"operations",f.session.getMapper(InventoryOperationMapper.class));ReflectionTestUtils.setField(inventory,"commands",f.session.getMapper(InventoryCommandMapper.class));ReflectionTestUtils.setField(inventory,"objectMapper",f.json); + ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway.class)); + ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins); + picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json); + ReflectionTestUtils.setField(picking,"physicalClaims",ReflectionTestUtils.getField(f.service,"physicalClaims"));ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots); + pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins); + balance(601,"101",5);position(101,601,"101",crossCustomer?2:5); + if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);} + application(201,"RL-A","101",9001,301,101,2,"A");application(202,"RL-B",crossCustomer?"102":"101",9002,302,crossCustomer?102:101,3,"B"); + } + /** 真实业务来源入口获取组,不构造客户端版本。 */ + DispatchPlanningModels.SelectionGroup group(){return rules.selectionGroup("9",new DispatchCommands.CargoSelection(SourceType.RELABEL,null,null,"RL-A",501L,null));} + /** 同一事务执行正式创建,真实选货来源替换中央夹具的受控普通计划。 */ + DispatchContracts.CommandResult create(String key,List selections){return f.tx.execute(s->f.service.create(f.actor,f.configured(key,selections)));} + /** 按业务来源定位逻辑明细,不将表ID作为业务单号。 */ + long item(String no){return f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE source_business_no=?",Long.class,no);} + /** 实际隔离库断言使用固定测试表和谓词。 */ + long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);} + /** 测试输入使用实际模块必填字段,不创建假结构列。 */ + void insert(String table,Object... fields){var columns=new ArrayList();var values=new ArrayList();for(int n=0;nf.service.create(f.actor,f.configured("pda-read",List.of(a,b))));f.approve(created); + f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8"); + f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)"); + f.db.update("INSERT INTO ll_inventory_position VALUES(11,'101',21,501,'9',0,0,0,0),(12,'202',22,502,'9',0,0,0,0),(13,'999',23,501,'99',100,0,0,0)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet(); + var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class); + var queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(mapper),f.service); + var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + var list=f.tx.execute(s->queries.list(actor,Stage.LOADING));assertThat(list).hasSize(1); + var task=list.get(0);assertThat(task.dispatchNo()).isEqualTo(created.dispatchNo());assertThat(task.pickingReconciled()).isTrue(); + assertThat(task.items()).extracting(DispatchViews.PdaItem::customerId).containsExactly("101","202"); + assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1)); + assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo()))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业"); + f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'"); + assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimPersistenceTest.java new file mode 100644 index 000000000..c6556b290 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimPersistenceTest.java @@ -0,0 +1,187 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.mapper.DispatchPhysicalClaimMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchPhysicalClaimRepository; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实模块DDL、MyBatis和事务证明物理拥有者不依赖任何第一来源客户。 */ +class DispatchPhysicalClaimPersistenceTest { + private static final LocalDateTime AT=LocalDateTime.of(2026,10,19,12,0); + + /** 同一主单双客户共享一条物理占用,另一操作人维护该主单也不变成跨车领取。 */ + @Test void twoCustomersOfOneDispatchShareExactlyOnePhysicalOwner() throws Exception { + var f=new Fixture();var owner=f.dispatch(10); + long physical=f.tx.execute(s->{long id=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,id,"101","RELABEL:A:501",10L,null,null,null);return id;}); + f.tx.executeWithoutResult(s->{assertThat(f.service.reserve(new DispatchPhysicalClaimService.Owner("9",10L,null,null,"another",null),List.of(501L),AT).get(501L)).isEqualTo(physical);f.member(2,physical,"202","RELABEL:B:501",10L,null,null,null);}); + assertThat(f.count("ll_dispatch_physical_pallet_lock","status='ACTIVE'")).isOne(); + assertThat(f.count("ll_dispatch_pallet_lock","physical_lock_id="+physical+" AND status='ACTIVE'")).isEqualTo(2); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT))).isInstanceOf(BizException.class).hasMessageContaining("其他有效运输"); + } + + /** 第一逻辑来源移除后仍保持物理排他,最后成员结束才可交给下一主单。 */ + @Test void firstMemberReleaseCannotExposeRemainingCustomerPallet() throws Exception { + var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L);f.member(1,id,"101","A",10L,null,null,null);f.member(2,id,"202","B",10L,null,null,null);return id;}); + f.tx.executeWithoutResult(s->{f.endMember(1);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isZero();}); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT))).isInstanceOf(BizException.class); + f.tx.executeWithoutResult(s->{f.endMember(2);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isOne();}); + long next=f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT).get(501L)); + assertThat(next).isNotEqualTo(physical); + f.tx.executeWithoutResult(s->assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isZero()); + assertThat(f.mapper.lockOriginal(next).getStatus()).isEqualTo("ACTIVE"); + } + + /** 最后一行释放后的后续失败必须恢复逻辑状态及原物理活动键。 */ + @Test void laterFailureRollsBackMemberAndPhysicalReleaseTogether() throws Exception { + var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L);f.member(1,id,"101","A",10L,null,null,null);return id;}); + assertThatThrownBy(()->f.tx.execute(s->{f.endMember(1);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isOne();throw new IllegalStateException("后续业务失败");})).hasMessageContaining("后续业务失败"); + assertThat(f.mapper.lockOriginal(physical).getActiveWarehousePalletId()).isEqualTo(501L); + assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE' AND active_key='A'")).isOne(); + } + + /** 草稿与容器共同拥有实托,同一token的另一容器不能把同托拆走。 */ + @Test void draftContainerIdentityIsPartOfExclusiveOwner() throws Exception { + var f=new Fixture();var first=f.draft("DRAFT",1,AT.plusMinutes(30)); + long physical=f.tx.execute(s->f.service.reserve(first,List.of(501L),AT).get(501L)); + Long replay=f.tx.execute(s->f.service.reserve(first,List.of(501L),AT).get(501L));assertThat(replay).isEqualTo(physical); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.draft("DRAFT",2,AT.plusMinutes(30)),List.of(501L),AT))).isInstanceOf(BizException.class); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(new DispatchPhysicalClaimService.Owner("88",10L,null,null,"operator",null),List.of(501L),AT))).hasMessageContaining("起运仓"); + } + + /** 过期原草稿全部成员失效后才能重领,迟到的旧释放不能清新主单活动键。 */ + @Test void expiryIsOwnedAndOldReleaseCannotAffectNewOwner() throws Exception { + var f=new Fixture();var draft=f.draft("OLD",1,AT.minusSeconds(1)); + long old=f.tx.execute(s->{long id=f.service.reserve(draft,List.of(501L),AT.minusMinutes(1)).get(501L);f.member(1,id,"101","A",null,"OLD",1L,AT.minusSeconds(1));f.member(2,id,"202","B",null,"OLD",1L,AT.minusSeconds(1));return id;}); + long next=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L)); + assertThat(f.mapper.lockOriginal(old).getStatus()).isEqualTo("EXPIRED");assertThat(f.count("ll_dispatch_pallet_lock","status='EXPIRED' AND active_key IS NULL")).isEqualTo(2); + f.tx.executeWithoutResult(s->assertThat(f.service.releaseIfUnused(draft,List.of(old),AT)).isZero()); + assertThat(f.mapper.lockOriginal(next).getActiveWarehousePalletId()).isEqualTo(501L); + } + + /** 到期时间不一致不能偷走仍有效成员,前面已失效的行也随本次失败恢复。 */ + @Test void nonexpiredMemberPreventsExpiredPhysicalHeadFromBeingStolen() throws Exception { + var f=new Fixture();var draft=f.draft("OLD",1,AT.minusSeconds(1)); + long old=f.tx.execute(s->{long id=f.service.reserve(draft,List.of(501L),AT.minusMinutes(1)).get(501L);f.member(1,id,"101","A",null,"OLD",1L,AT.minusSeconds(1));f.member(2,id,"202","B",null,"OLD",1L,AT.plusMinutes(1));return id;}); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(501L),AT))).hasMessageContaining("尚未完整到期"); + assertThat(f.mapper.lockOriginal(old).getStatus()).isEqualTo("ACTIVE");assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE'")).isEqualTo(2); + } + + /** 部分成员先迁移时物理拥有者不能变,整组迁移后一次更新成功。 */ + @Test void draftMoveRequiresAllPhysicalMembersInTheSameNextContainer() throws Exception { + var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.draft("DRAFT",1,AT.plusMinutes(30)),List.of(501L),AT).get(501L);f.member(1,id,"101","A",null,"DRAFT",1L,AT.plusMinutes(30));f.member(2,id,"202","B",null,"DRAFT",1L,AT.plusMinutes(30));return id;}); + f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");assertThat(f.repository.moveDraft(physical,"DRAFT","operator",1,2,AT)).isZero();f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=2");assertThat(f.repository.moveDraft(physical,"DRAFT","operator",1,2,AT)).isOne();}); + assertThat(f.mapper.lockOriginal(physical).getDraftContainerId()).isEqualTo(2L); + } + + /** 服务只在全部成员已迁移时更新原物理拥有者,失败会回滚已移的逻辑成员。 */ + @Test void moveServiceRollsBackPartialMembersAndThenMovesWholePallet() throws Exception { + var f=new Fixture();var owner=f.draft("MOVE",1,AT.plusMinutes(30)); + long id=f.tx.execute(s->{long head=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,head,"101","A",null,"MOVE",1L,owner.expiresAt());f.member(2,head,"102","B",null,"MOVE",1L,owner.expiresAt());return head;}); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(id),2,AT);})).hasMessageContaining("全部逻辑来源"); + assertThat(f.count("ll_dispatch_pallet_lock","draft_container_id=1")).isEqualTo(2);assertThat(f.mapper.lockOriginal(id).getDraftContainerId()).isEqualTo(1L); + f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2");f.service.moveDraft(owner,List.of(id),2,AT);}); + assertThat(f.mapper.lockOriginal(id).getDraftContainerId()).isEqualTo(2L); + } + + /** 物理续期核对所有原租约,错误仓/过期记录/不同租约均不能部分续期。 */ + @Test void renewServiceRequiresExactOriginalLeaseAndCannotReviveExpiredClaims() throws Exception { + var f=new Fixture();var owner=f.draft("LEASE",1,AT.plusMinutes(10)); + long id=f.tx.execute(s->{long head=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,head,"101","A",null,"LEASE",1L,owner.expiresAt());return head;}); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("99","LEASE","operator",AT,owner.expiresAt(),AT.plusMinutes(30)))).hasMessageContaining("归属或原租约"); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,AT.plusMinutes(11),AT.plusMinutes(30)))).hasMessageContaining("归属或原租约"); + f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,owner.expiresAt(),AT.plusMinutes(30))); + assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30)); + f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,AT.plusMinutes(30),AT.plusMinutes(30))); + assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30)); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT.plusMinutes(31),AT.plusMinutes(30),AT.plusMinutes(50)))).hasMessageContaining("租约无效"); + assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30)); + } + + /** 容器中只迁移所选整托,另一托不动;混入他人占用须连已移动成员一起回滚。 */ + @Test void movingOneOfTwoPalletsKeepsOtherPalletAndRejectsForeignClaim() throws Exception { + var f=new Fixture();var owner=f.draft("SUBSET",1,AT.plusMinutes(30)); + var heads=f.tx.execute(s->{var all=f.service.reserve(owner,List.of(501L,502L),AT);f.member(1,all.get(501L),"101","A",null,"SUBSET",1L,owner.expiresAt());f.member(2,all.get(502L),"102","B",null,"SUBSET",1L,owner.expiresAt());return all;}); + long foreign=f.tx.execute(s->f.service.reserve(f.draft("OTHER",3,AT.plusMinutes(30)),List.of(503L),AT).get(503L)); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(heads.get(501L),foreign),2,AT);})).hasMessageContaining("拥有者已变化"); + assertThat(f.count("ll_dispatch_pallet_lock","draft_container_id=1")).isEqualTo(2); + f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(heads.get(501L)),2,AT);}); + assertThat(f.mapper.lockOriginal(heads.get(501L)).getDraftContainerId()).isEqualTo(2L);assertThat(f.mapper.lockOriginal(heads.get(502L)).getDraftContainerId()).isEqualTo(1L); + assertThat(f.mapper.lockOriginal(foreign).getDraftContainerId()).isEqualTo(3L); + } + + /** 两个事务都读到未占用后竞争插入,活动实托唯一键仍只允许一个主单提交。 */ + @Test void concurrentDifferentDispatchesCannotAcquireTheSamePallet() throws Exception { + var f=new Fixture();var real=f.repository;var intercepted=spy(real);var bothRead=new CyclicBarrier(2); + doAnswer(call->{var found=real.lockActive(call.getArgument(0));if(found==null)bothRead.await(5,TimeUnit.SECONDS);return found;}).when(intercepted).lockActive(501L); + var service=new DispatchPhysicalClaimService(intercepted);var pool=Executors.newFixedThreadPool(2); + try { + var tasks=new ArrayList>(); + for(int index=1;index<=2;index++) {final int own=index;tasks.add(pool.submit(()->{ + try {f.tx.executeWithoutResult(s->{long id=service.reserve(f.dispatch(own*10),List.of(501L),AT).get(501L);f.member(own,id,String.valueOf(own),"SOURCE-"+own,(long)own*10,null,null,null);});return "APPLIED";} + catch(BizException conflict){return "CONFLICT";} + }));} + assertThat(List.of(tasks.get(0).get(10,TimeUnit.SECONDS),tasks.get(1).get(10,TimeUnit.SECONDS))).containsExactlyInAnyOrder("APPLIED","CONFLICT"); + } finally {pool.shutdownNow();assertThat(pool.awaitTermination(5,TimeUnit.SECONDS)).isTrue();} + assertThat(f.count("ll_dispatch_physical_pallet_lock","status='ACTIVE'")).isOne();assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE'")).isOne(); + } + + /** 原主键释放查询使用物理顺序,即使创建时间与托盘编号顺序相反。 */ + @Test void releaseLocksOriginalReferencesByPhysicalPalletOrder() throws Exception { + var f=new Fixture();long large=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(900L),AT).get(900L)); + long small=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(100L),AT).get(100L)); + var traced=spy(f.repository);var service=new DispatchPhysicalClaimService(traced); + f.tx.executeWithoutResult(s->assertThat(service.releaseIfUnused(f.dispatch(10),List.of(large,small),AT)).isEqualTo(2)); + var locks=inOrder(traced);locks.verify(traced).lockOriginal(small);locks.verify(traced).lockOriginal(large); + } + + /** 空物理集合不伪造托盘,缺失或非法真实引用会在访问数据库前拒绝。 */ + @Test void nonphysicalSourcesDoNotCreatePlaceholderClaims() throws Exception { + var f=new Fixture();Map empty=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(),AT));assertThat(empty).isEmpty(); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(0L),AT))).isInstanceOf(IllegalArgumentException.class); + assertThat(f.count("ll_dispatch_physical_pallet_lock","1=1")).isZero(); + } + + /** 测试仅用本机隔离H2;表定义直接截取模块基线,不连接共享环境。 */ + static class Fixture { + final JdbcTemplate db;final TransactionTemplate tx;final DispatchPhysicalClaimMapper mapper; + final DispatchPhysicalClaimRepository repository;final DispatchPhysicalClaimService service; + /** 实际Mapper与Spring事务共用数据源,唯一键及失败回滚均由数据库执行。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); + db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchPhysicalClaimMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchPhysicalClaimMapper.class);repository=new DispatchPhysicalClaimRepository(mapper);service=new DispatchPhysicalClaimService(repository); + String schema;try(var in=getClass().getResourceAsStream("/db/lastleg.sql")){schema=new String(Objects.requireNonNull(in).readAllBytes(),StandardCharsets.UTF_8);} + for(var table:List.of("ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock")) { + int start=schema.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");int end=schema.indexOf(") ENGINE=",start); + if(start<0 || end<0)throw new IllegalStateException("模块物理占用表缺失");db.execute(schema.substring(start,end+1)); + } + } + /** 正式主单拥有者不绑定客户。 */ + DispatchPhysicalClaimService.Owner dispatch(long id){return new DispatchPhysicalClaimService.Owner("9",id,null,null,"operator",null);} + /** 草稿拥有者使用真实token、容器、操作人及期限。 */ + DispatchPhysicalClaimService.Owner draft(String token,long container,LocalDateTime expires){return new DispatchPhysicalClaimService.Owner("9",null,token,container,"operator",expires);} + /** 逻辑成员只是本切片准备输入,物理占用和释放始终走被测生产Mapper。 */ + void member(long id,long physical,String customer,String key,Long dispatch,String token,Long container,LocalDateTime expires){ + db.update("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,warehouse_pallet_id,dispatch_order_id,draft_token,draft_container_id,operator_id,status,locked_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE',?,?)",id,physical,customer,key,key,mapper.selectById(physical).getWarehousePalletId(),dispatch,token,container,"operator",AT.minusMinutes(1),expires); + } + /** 模拟已校验的逻辑来源结束,为最后成员物理保护提供真实数据库输入。 */ + void endMember(long id){db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE id=?",id);} + /** 核对隔离测试库实际状态。 */ + long count(String table,String predicate){return Objects.requireNonNull(db.queryForObject("SELECT COUNT(*) FROM "+table+" WHERE "+predicate,Long.class));} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java new file mode 100644 index 000000000..05963953b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ +class DispatchPickingPersistenceTest { + /** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */ + @Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception { + var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码"); + var result=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,request)); + assertThat(result.stage()).isEqualTo(Stage.LOADING);assertThat(((DispatchResults.Picked)result.result()).pickingReconciled()).isTrue(); + var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,request)); + assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion()); + verify(fixture.inventory,times(1)).ship(eq("101"),eq("picker"),any()); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isOne(); + assertThat(fixture.f.db.queryForObject("SELECT task_no FROM ll_dispatch_pda_task",String.class)).isEqualTo(fixture.number); + assertThat(fixture.f.db.queryForObject("SELECT customer_id FROM ll_dispatch_pda_pick",String.class)).isEqualTo("101"); + assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); + assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isZero(); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(new DispatchContracts.Actor("someone-else",ActorMode.PDA,"9"),fixture.number,fixture.item,request))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verify(fixture.inventory,times(1)).ship(any(),any(),any()); + } + /** 不同真实客户共用唯一整车任务,客户归属留在扫码明细和库存命令。 */ + @Test void twoCustomersUseOneTaskAndKeepEachRealPickOwner() throws Exception { + var fixture=new Picking(true); + var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,new DispatchCommands.Pick(fixture.version,"first-customer",11L,3,6,null,null))); + assertThat(((DispatchResults.Picked)first.result()).pickingReconciled()).isFalse(); + var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem,new DispatchCommands.Pick(first.recordVersion(),"second-customer",12L,2,4,null,null))); + assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); + assertThat(second.stage()).isEqualTo(Stage.LOADING); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isOne(); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(DISTINCT task_id) FROM ll_dispatch_pda_pick",Long.class)).isOne(); + assertThat(fixture.f.db.queryForList("SELECT customer_id FROM ll_dispatch_pda_pick ORDER BY customer_id",String.class)).containsExactly("101","102"); + assertThat(Arrays.stream(DispatchPdaTask.class.getDeclaredFields()).map(java.lang.reflect.Field::getName)).doesNotContain("customerId"); + verify(fixture.inventory).ship(eq("101"),eq("picker"),any());verify(fixture.inventory).ship(eq("102"),eq("picker"),any()); + } + /** 归属失败时连模拟库存SQL也回滚,不能留下仅库存已扣而没有扫码/账本的部分结果。 */ + @Test void attributionFailureRollsBackInventoryPickTaskHeaderAndLedger() throws Exception { + var fixture=new Picking();doThrow(new IllegalStateException("模拟归属失败")).when(fixture.attribution).attribute(eq("101"),any(DispatchPdaPick.class),isNull()); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,new DispatchCommands.Pick(fixture.version,"rollback-pick",11L,3,6,null,null)))).isInstanceOf(IllegalStateException.class); + assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isEqualTo(3); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero();assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isZero(); + assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='rollback-pick'",Long.class)).isZero(); + } + + /** 真主单和实际MyBatis写入;仅模拟两个独立应用命令提供者,未连接真实环境。 */ + private static final class Picking { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class); + final DispatchPickingService service;final String number;final long item,version;final Long secondItem; + final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + /** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */ + Picking() throws Exception {this(false);} + Picking(boolean multipleCustomers) throws Exception { + var selections=new ArrayList();selections.add(f.source(1,"101",501,"ONT8",3)); + if(multipleCustomers)selections.add(f.source(2,"102",502,"LAX9",2)); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("picking-create",selections)));number=created.dispatchNo();version=f.approve(created).recordVersion(); + item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class); + secondItem=multipleCustomers?f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='102'",Long.class):null; + f.db.update("INSERT INTO ll_order(id,customer_id,order_no,source_region,source_inbound_id)VALUES(9001,'101','ORDER-1','OVERSEAS','SOURCE-1')"); + f.db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,inbound_code)VALUES(1,'101','IN-1')"); + if(multipleCustomers){f.db.update("INSERT INTO ll_order(id,customer_id,order_no,source_region,source_inbound_id)VALUES(9002,'102','ORDER-2','OVERSEAS','SOURCE-2')");f.db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,inbound_code)VALUES(2,'102','IN-2')");} + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + for(var mapper:List.of(DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,InventoryPositionMapper.class,DispatchItemMapper.class))config.addMapper(mapper); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();var session=new SqlSessionTemplate(factory.getObject()); + f.db.execute("DROP TABLE ll_dispatch_pda_pick"); + for(var type:List.of(DispatchPdaPick.class,DispatchPdaTask.class,com.baiyue.lastleg.inventory.entity.InventoryPosition.class)) { + var table=TableInfoHelper.getTableInfo(type);var columns=new ArrayList();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); + for(var field:table.getFieldList())columns.add(field.getColumn()+" "+sqlType(field.getPropertyType()));f.db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")"); + } + f.db.execute("ALTER TABLE ll_dispatch_pda_task ADD CONSTRAINT unique_task_dispatch UNIQUE(dispatch_order_id)"); + f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version)VALUES(11,'101',21,501,'PL-501','9',3,0,0,0,1)"); + if(multipleCustomers)f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version)VALUES(12,'102',22,502,'PL-502','9',2,0,0,0,1)"); + doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any()); + doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));}) + .when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull()); + var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); + service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json); + for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); + } + /** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */ + private static String sqlType(Class type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java index 354f984ba..2d4e9bc52 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -10,15 +10,13 @@ import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; import com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard; -import com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery; import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository; import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; import com.baiyue.lastleg.dispatch.mapper.*; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.mapper.*; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService; @@ -26,9 +24,18 @@ import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.fleet.trip.*; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.relabel.mapper.*; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService; import com.baiyue.lastleg.relabel.service.RelabelBindingGuard; import com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; @@ -36,15 +43,12 @@ import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; -import org.apache.ibatis.session.LocalCacheScope; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.server.ResponseStatusException; @@ -70,135 +74,116 @@ class DispatchRepairWorkflowTest { private SqlSessionTemplate session; private final Map, Object> dependencies = new LinkedHashMap<>(); private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); - private DispatchService dispatch; + private DispatchAggregateService dispatch; + private DispatchService sourceRules; + private DispatchAggregatePersistenceTest.Fixture aggregateFixture; private DeliveryTransferService delivery; private CustomerVisitService visits; private ExceptionOrderService exceptions; private DispatchFleetAdapter fleet; private DispatchCandidateMapper candidates; private OverseasFeeSourceService fees; + private final Map cancellationCommands=new LinkedHashMap<>(); - /** 每个用例使用独立H2库,不连接运行环境;真实Mapper元数据生成最小测试表。 */ + /** 复用公开整车夹具的真实资源/占用/账本,扩展只发生在本测试的独立数据库。 */ @BeforeEach void setUp() throws Exception { - var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() - + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); - db = new JdbcTemplate(source); - transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); - var configuration = new MybatisConfiguration(); - configuration.setMapUnderscoreToCamelCase(true); - configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); - List> mappers = List.of(DispatchOrderMapper.class, DispatchItemMapper.class, - DispatchPalletLockMapper.class, DispatchBolMapper.class, DispatchPdaTaskMapper.class, - DispatchPdaPickMapper.class, DispatchPdaPickOrderMapper.class, OutboundOrderMapper.class, - OutboundPalletItemMapper.class, OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, - DeliveryOrderMapper.class, DeliveryOperationMapper.class, CustomerVisitMapper.class, - LastlegOrderMapper.class, LastlegInboundOrderMapper.class, LastlegOrderStatusHistoryMapper.class, - FleetTripDispatchMapper.class, FleetTripMapper.class, FleetBookingMapper.class, InventoryPositionMapper.class, - InventoryBalanceMapper.class, DispatchFeeCargoMapper.class, ExceptionReturnItemMapper.class, ExceptionOrderMapper.class, - RelabelApplicationMapper.class, RelabelItemMapper.class, PdaRelabelHistoryMapper.class, - RelabelPalletMeasurementMapper.class, ReturnInventoryCartonMapper.class, DispatchCandidateMapper.class, - WarehouseTransferOrderMapper.class); - mappers.forEach(configuration::addMapper); - var factory = new MybatisSqlSessionFactoryBean(); - factory.setDataSource(source); - factory.setConfiguration(configuration); - factory.afterPropertiesSet(); - session = new SqlSessionTemplate(factory.getObject()); - for (Class mapper : mappers) dependencies.put(mapper, session.getMapper(mapper)); - // 不复制正式SQL,也不声称验证生产DDL;只构造本文件使用的实体表。 - for (Class mapper : mappers) for (var parent : mapper.getGenericInterfaces()) { - if (!(parent instanceof java.lang.reflect.ParameterizedType generic) - || generic.getRawType() != com.baomidou.mybatisplus.core.mapper.BaseMapper.class) continue; - var table = TableInfoHelper.getTableInfo((Class) generic.getActualTypeArguments()[0]); - var columns = new ArrayList(); - if (table.havePK()) columns.add(table.getKeyColumn() + " " + sqlType(table.getKeyType()) - + (table.getIdType() == com.baomidou.mybatisplus.annotation.IdType.AUTO ? " AUTO_INCREMENT" : "") + " PRIMARY KEY"); - table.getFieldList().forEach(field -> columns.add(field.getColumn() + " " + sqlType(field.getPropertyType()))); - db.execute("CREATE TABLE IF NOT EXISTS " + table.getTableName() + " (" + String.join(",", columns) + ")"); + aggregateFixture=new DispatchAggregatePersistenceTest.Fixture();db=aggregateFixture.db;transactions=aggregateFixture.tx;session=aggregateFixture.session; + List> mappers=List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,DispatchBolMapper.class,DispatchPdaTaskMapper.class, + DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaTaskMapper.class,OutboundPdaPickMapper.class, + DeliveryOrderMapper.class,DeliveryOperationMapper.class,CustomerVisitMapper.class,LastlegOrderMapper.class,LastlegInboundOrderMapper.class,LastlegOrderStatusHistoryMapper.class, + FleetTripDispatchMapper.class,FleetTripMapper.class,FleetBookingMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,DispatchFeeCargoMapper.class, + ExceptionReturnItemMapper.class,ExceptionOrderMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,PdaRelabelHistoryMapper.class,RelabelPalletMeasurementMapper.class, + ReturnInventoryCartonMapper.class,DispatchCandidateMapper.class,WarehouseTransferOrderMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class, + RelabelDispatchSourceMapper.class,PdaRelabelTaskMapper.class,PdaRelabelUnitFactMapper.class,PhysicalPalletSerialMapper.class); + for(var mapper:mappers) { + if(!session.getConfiguration().hasMapper(mapper))session.getConfiguration().addMapper(mapper); + dependencies.put(mapper,session.getMapper(mapper)); + for(var parent:mapper.getGenericInterfaces()) { + if(!(parent instanceof java.lang.reflect.ParameterizedType generic)||generic.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue; + var table=TableInfoHelper.getTableInfo((Class)generic.getActualTypeArguments()[0]); + db.execute("CREATE TABLE IF NOT EXISTS "+table.getTableName()+"("+table.getKeyColumn()+" "+sqlType(table.getKeyType())+" PRIMARY KEY)"); + for(var field:table.getFieldList())if(!table.getTableName().equals("ll_fleet_trip_dispatch") || !List.of("customer_id","review_json").contains(field.getColumn()))db.execute("ALTER TABLE "+table.getTableName()+" ADD COLUMN IF NOT EXISTS "+field.getColumn()+" "+sqlType(field.getPropertyType())); + } } - db.execute("CREATE UNIQUE INDEX test_dispatch_active_lock ON ll_dispatch_pallet_lock(customer_id,active_key)"); - // 普通换标与退货换标候选查询含MySQL专用聚合;固定输入不替代完整集合校验或状态写入。 - candidates = spy(session.getMapper(DispatchCandidateMapper.class)); - doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(), any(), any()); - dependencies.put(DispatchCandidateMapper.class, candidates); - dependencies.put(ObjectMapper.class, json); - dependencies.put(LastlegOrderNoGenerator.class, new LastlegOrderNoGenerator()); - dependencies.put(FleetDispatchBindingGuard.class, - new FleetDispatchBindingGuard(mapper(FleetTripDispatchMapper.class), json)); - dependencies.put(FleetDispatchWorkflowQuery.class, - new FleetDispatchWorkflowQuery(mapper(FleetTripDispatchMapper.class), mapper(FleetTripMapper.class), json)); - var execution = new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class)); - dependencies.put(DeliveryExecutionRepository.class, execution); - dependencies.put(DeliveryExecutionGuard.class, new DeliveryExecutionGuard(execution)); - dependencies.put(DispatchCargoSourceQuery.class, - new DispatchCargoSourceQuery(new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class)))); - dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class, - mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); - dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class, - mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class)); - fees = mock(OverseasFeeSourceService.class); - dependencies.put(OverseasFeeSourceService.class, fees); - var binding = wire(new RelabelBindingGuard()); - var measurements = wire(new RelabelPalletMeasurementService()); - var returnStock = wire(new ReturnRelabelDispatchStock()); - dependencies.put(RelabelBindingGuard.class, binding); - dependencies.put(RelabelPalletMeasurementService.class, measurements); - dependencies.put(ReturnRelabelDispatchStock.class, returnStock); - dispatch = wire(new DispatchService()); - delivery = wire(new DeliveryTransferService()); - visits = wire(new CustomerVisitService()); - exceptions = wire(new ExceptionOrderService()); - fleet = new DispatchFleetAdapter(mapper(DispatchOrderMapper.class), mapper(DispatchPdaTaskMapper.class), - mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class), mapper(DispatchItemMapper.class), - mapper(DispatchPdaPickMapper.class), new DispatchPdaWorkflowService(), mapper(DeliveryOperationMapper.class)); - wire(fleet); + db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)"); + db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet(id BIGINT,customer_id VARCHAR(128) NOT NULL,plan_id BIGINT,pallet_no VARCHAR(128),warehouse_pallet_id BIGINT)"); + db.execute("CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))"); + for(var column:List.of("order_id BIGINT","warehouse_pallet_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT"))db.execute("ALTER TABLE ll_inbound_stock_receipt ADD COLUMN IF NOT EXISTS "+column); + candidates=spy(session.getMapper(DispatchCandidateMapper.class));doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(),any(),any());dependencies.put(DispatchCandidateMapper.class,candidates); + dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,new LastlegOrderNoGenerator()); + var sourceRepository=new com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository(candidates,session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)); + dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.class,sourceRepository);dependencies.put(DispatchCommandHasher.class,new DispatchCommandHasher(json)); + var relabelSources=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(mapper(RelabelDispatchSourceMapper.class)),json); + dependencies.put(RelabelDispatchSourcePort.class,relabelSources); + dependencies.put(PhysicalPalletSerialPort.class,new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(mapper(PhysicalPalletSerialMapper.class)))); + dependencies.put(RelabelBindingGuard.class,wire(new RelabelBindingGuard()));dependencies.put(RelabelPalletMeasurementService.class,wire(new RelabelPalletMeasurementService())); + dependencies.put(ReturnRelabelDispatchStock.class,wire(new ReturnRelabelDispatchStock()));sourceRules=wire(new DispatchService()); + dispatch=aggregateFixture.service;ReflectionTestUtils.setField(dispatch,"sourceRules",sourceRules); + ReflectionTestUtils.setField(dispatch,"relabelSources",new DispatchItemSourceService( + new com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository(session.getMapper(DispatchItemSourceMapper.class)),relabelSources,json)); + dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository.class,aggregateFixture.repository); + dependencies.put(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class,dispatch); + var cargo=new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class)); + fleet=new DispatchFleetAdapter(aggregateFixture.repository,mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class),mapper(DeliveryOperationMapper.class),cargo,dispatch,json); + dependencies.put(FleetDispatchBindingGuard.class,new FleetDispatchBindingGuard(mapper(FleetTripDispatchMapper.class),fleet)); + dependencies.put(FleetDispatchWorkflowQuery.class,new FleetDispatchWorkflowQuery(mapper(FleetTripDispatchMapper.class),fleet)); + var execution=new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class));dependencies.put(DeliveryExecutionRepository.class,execution);dependencies.put(DeliveryExecutionGuard.class,new DeliveryExecutionGuard(execution)); + dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class,new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class),mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class),mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class),mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class))); + fees=mock(OverseasFeeSourceService.class);dependencies.put(OverseasFeeSourceService.class,fees); + delivery=wire(new DeliveryTransferService());visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService()); + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("OP","", "platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:cancel","platform:overseas-warehouse:dispatch:transfer-delivery")); } - + /** 真实权限上下文不泄漏到其他测试。 */ + @org.junit.jupiter.api.AfterEach void clearSecurity(){org.springframework.security.core.context.SecurityContextHolder.clearContext();} /** 历史取消出库仍可追溯;取消旧排车后同货重排,重复取消不得释放新锁。 */ @Test void cancelledOutboundThenDispatchCancellationAllowsNewArrangementWithoutReleasingItsLock() { seedPendingOutbound(); - db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,plan_pallet_id,warehouse_pallet_id,source_pallet_key,active_key,status) VALUES(40,'C',1,900,501,'PLAN:900','PLAN:900','ACTIVE')"); + var source=seedPlanSource(); + db.update("UPDATE ll_dispatch_item SET source_key='PLAN:900',plan_pallet_id=900,order_id=9900,source_stage='RECEIVED',source_business_type='INBOUND_PLAN' WHERE id=4"); + db.update("INSERT INTO ll_dispatch_physical_pallet_lock(id,warehouse_id,warehouse_pallet_id,active_warehouse_pallet_id,dispatch_order_id,operator_id,status) VALUES(41,'9',501,501,1,'OP','ACTIVE')"); + db.update("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,dispatch_order_id,plan_pallet_id,warehouse_pallet_id,source_pallet_key,active_key,status) VALUES(40,41,'101',1,900,501,'PLAN:900','PLAN:900','ACTIVE')"); run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重新安排"))); assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("CANCELLED"); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("CANCELLED"); long operations = count("ll_delivery_operation"); - run(() -> dispatch.cancel("DP-1")); + run(() -> cancel("DP-1")); assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("CANCELLED"); assertThat(text("ll_dispatch_pallet_lock", "status", 40)).isEqualTo("RELEASED"); assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=40", String.class)).isNull(); - doReturn(List.of(candidate(501).planPalletId(900L))).when(candidates).lockSelected("C", List.of(900L)); - var created = inTransaction(() -> dispatch.preArrange("C", "OP", - new DispatchRequests.PreArrange(List.of(900L), null, null, null))); + assertThat(text("ll_dispatch_physical_pallet_lock","status",41)).isEqualTo("RELEASED"); + var created = inTransaction(() -> dispatch.create(aggregateFixture.actor,create(List.of(source)))); assertThat(created.dispatchNo()).isNotEqualTo("DP-1"); - assertThat(created.actions().canCancel()).isTrue(); - assertThat(created.actions().canTransfer()).isTrue(); + var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo()); + assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue(); + assertThat(current.actions().get(DispatchTypes.Action.ARRANGE_DELIVERY).allowed()).isFalse(); + assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId()); run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重复取消"))); - run(() -> dispatch.cancel("DP-1")); + run(() -> cancel("DP-1")); assertThat(count("ll_outbound_order")).isEqualTo(1); assertThat(count("ll_delivery_operation")).isEqualTo(operations); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); - verify(fees, times(1)).listForBusiness("C", "DELIVERY_ORDER", 3L); + verify(fees, times(1)).listForBusiness("101", "DELIVERY_ORDER", 3L); verify(fees, never()).voidSource(anyLong(), any(), any()); } - /** 真实车次关联和已拣事实分别阻止取消,失败事务不修改单据及占用。 */ + /** 唯一自动车次关系保持;真实已拣阻止取消,失败事务不修改单据及关系。 */ @Test - void bindingThenPickingIndependentlyPreventOutboundCancellation() { + void automaticTripRelationshipRemainsWhilePickingPreventsOutboundCancellation() { seedPendingOutbound(); - db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(51,'TR-1',1,'DP-1','C')"); - assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消")))) - .isInstanceOf(ResponseStatusException.class).hasMessageContaining("解除关联"); - assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); - db.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=1"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch WHERE dispatch_id=1",Long.class)).isEqualTo(1); + var binding=db.queryForMap("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=1"); db.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=1"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,dispatch_item_id,packages) VALUES(103,'101',4,1)"); assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消")))) .isInstanceOf(ResponseStatusException.class); assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); assertThat(count("ll_delivery_operation")).isZero(); + assertThat(db.queryForMap("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=1")).isEqualTo(binding); } /** 未出库开始派送/拒签均拒绝;真实出库后允许开始,但普通状态编辑始终不能取消。 */ @@ -206,23 +191,23 @@ class DispatchRepairWorkflowTest { @ValueSource(strings = {"DELIVERING", "REJECTED"}) void deliveryActionsFollowActualOutboundAndNeverOfferAnIndependentCancellation(String target) { seedPendingOutbound(); - assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1", new CustomerVisitRequests.DeliveryStatus(target, 0L, "提前操作")))) .isInstanceOf(ResponseStatusException.class); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); assertThat(count("ll_delivery_operation")).isZero(); - db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=2 WHERE id=1"); + db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',shipped_package_count=2,departed_at=CURRENT_TIMESTAMP WHERE id=1"); db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2"); - assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1", new CustomerVisitRequests.DeliveryStatus("CANCELLED", 0L, "已发车取消")))) .isInstanceOf(ResponseStatusException.class); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); - run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1", new CustomerVisitRequests.DeliveryStatus(target, 0L, "合法操作"))); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo(target); assertThat(db.queryForObject("SELECT version FROM ll_delivery_order WHERE id=3", Long.class)).isEqualTo(1); long operations = count("ll_delivery_operation"); - var replay = inTransaction(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + var replay = inTransaction(() -> visits.updateDeliveryStatus("101", "OP", "DL-1", new CustomerVisitRequests.DeliveryStatus(target, 0L, "同状态重放"))); assertThat(replay.replayed()).isTrue(); assertThat(count("ll_delivery_operation")).isEqualTo(operations); @@ -232,52 +217,68 @@ class DispatchRepairWorkflowTest { @Test void departureManifestRejectsCancelledMissingAndIncompleteDeliveryCoverage() { seedPendingOutbound(); - db.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=2 WHERE id=1"); - db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')"); - db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages) VALUES(103,'C',102,4,104,2)"); + db.update("UPDATE ll_dispatch_order SET stage='AFTER_LOADING',picked_package_count=2 WHERE id=1"); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,status,warehouse_id) VALUES(102,1,'COMPLETED','9')"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages) VALUES(103,'101',102,4,104,2)"); + db.update("INSERT INTO ll_order(id,customer_id,order_no) VALUES(100,'101','LM-100')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(104,'101',501,'9',2)"); + db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,order_id,warehouse_pallet_id,inventory_position_id,received_packages,inbound_code) VALUES(106,'101',100,501,104,2,'IN-100')"); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,receipt_id,order_id,packages) VALUES(105,'101',103,106,100,2)"); assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNull(); - db.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=3"); - assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); var guard = (DeliveryExecutionGuard) dependencies.get(DeliveryExecutionGuard.class); - assertThatThrownBy(() -> run(() -> guard.requireDeparture(mapper(OutboundOrderMapper.class).findByNo("C", "OUT-1")))) + assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNull(); + db.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=3"); + assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank(); + assertThatThrownBy(() -> run(() -> guard.requireDeparture(mapper(OutboundOrderMapper.class).findByNo("101", "OUT-1")))) .isInstanceOf(ResponseStatusException.class); db.update("UPDATE ll_delivery_order SET status='WAIT_DELIVERY',package_count=1 WHERE id=3"); - assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); + assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank(); db.update("DELETE FROM ll_delivery_order WHERE id=3"); - assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); - assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("READY_TO_DEPART"); + assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank(); + assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("ACTIVE"); + assertThat(text("ll_dispatch_order", "stage", 1)).isEqualTo("AFTER_LOADING"); assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); } /** 两托部分选取和重复ID都必须零副作用,完整预排取消后仍可整单重新预排。 */ @Test void ordinaryRelabelRequiresAllPalletsAndCanBeRearrangedAfterCancellation() { - db.update("INSERT INTO ll_relabel_application(id,customer_id,relabel_no,overseas_warehouse_id,status,fulfillment_status,delivery_method) VALUES(70,'C','RL-1','WH-1','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH','TRUCK')"); + var confirmedAt=LocalDateTime.of(2026,10,19,12,5); + db.update("INSERT INTO ll_relabel_application(id,customer_id,relabel_no,lastleg_order_id,overseas_warehouse_id,status,fulfillment_status,delivery_method,total_package_count,notified_package_count,item_count,updated_at,customer_confirmed_at) VALUES(70,'101','RL-1',9501,'9','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH','TRUCK',2,2,2,?,?)",confirmedAt,confirmedAt); + db.update("INSERT INTO ll_relabel_pda_task(id,application_id,customer_id,relabel_no,overseas_warehouse_id,status,reviewed_at,version) VALUES(72,70,'101','RL-1',9,'COMPLETED',?,1)",confirmedAt); + db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,detained,package_count,overseas_warehouse_id) VALUES(9501,'101','LM-RL','RL-1',0,2,'9')"); for (long pallet : List.of(501L, 502L)) { - db.update("INSERT INTO ll_inventory_position(id,customer_id,pallet_id,pallet_no,overseas_warehouse_id,hold_packages) VALUES(?,'C',?,?,'WH-1',1)", pallet, pallet, "P-" + pallet); - db.update("INSERT INTO ll_relabel_item(id,customer_id,application_id,operation_type,source_inventory_position_id,carton_mark,notified_package_count) VALUES(?,'C',70,'CARTON_LABEL',?,?,1)", pallet, pallet, "BOX-" + pallet); + db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(?,'101',?,1)",7000+pallet,"IN-"+pallet); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(?,'101',?,?)",pallet,7000+pallet,"IN-"+pallet); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version) VALUES(?,'101',?,?,?,'9',0,1,0,0,0)", pallet,pallet,pallet, "P-" + pallet); + db.update("INSERT INTO ll_relabel_item(id,customer_id,application_id,operation_type,source_inventory_position_id,source_inventory_balance_id,source_inbound_order_id,source_inbound_order_no,source_inbound_code,carton_mark,notified_package_count,total_package_count) VALUES(?,'101',70,'CARTON_LABEL',?,?,?,?,?,?,1,1)", + pallet,pallet,pallet,7000+pallet,"IN-"+pallet,"IN-"+pallet,"BOX-"+pallet); + db.update("INSERT INTO ll_relabel_pda_unit_fact(id,task_id,application_id,item_id,customer_id,old_label,new_label,result,quantity,occurred_at) VALUES(?,72,70,?,'101',?,?,'RELABELLED',1,?)", + 8000+pallet,pallet,"BOX-"+pallet,"NEW-BOX-"+pallet,confirmedAt.minusMinutes(3)); } - db.update("INSERT INTO ll_relabel_pda_history(id,customer_id,application_id,action,detail_json) VALUES(71,'C',70,'PALLET_BIND',?)", - "{\"cartonMarks\":[\"BOX-501\",\"BOX-502\"],\"targetPallets\":[\"P-501\",\"P-502\"]}"); - doReturn(List.of(candidate(501).orderRefs("RL-1").packageCount(1), candidate(502).orderRefs("RL-1").packageCount(1))) - .when(candidates).relabelAvailable("C", null, "RL-1"); + db.update("INSERT INTO ll_relabel_pda_history(id,task_id,customer_id,application_id,action,detail_json,created_at) VALUES(71,72,'101',70,'PALLET_BIND',?,?)", + "{\"round\":\"INITIAL\",\"cartonMarks\":[\"BOX-501\",\"BOX-502\"],\"targetPallets\":[\"P-501\",\"P-502\"]}",confirmedAt.minusMinutes(4)); + db.update("INSERT INTO ll_relabel_pda_history(id,task_id,customer_id,application_id,action,detail_json,created_at) VALUES(73,72,'101',70,'COMPLETE',?,?)", + "{\"roundKey\":\"INITIAL\"}",confirmedAt.minusMinutes(2)); + doReturn(List.of(relabelCandidate(501),relabelCandidate(502))) + .when(candidates).relabelAvailable("101", null, "RL-1"); for (var selection : List.of(List.of(501L), List.of(501L, 501L, 502L))) { - assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(selection)))) + assertThatThrownBy(() -> inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(selection)))) .isInstanceOf(ResponseStatusException.class); assertThat(count("ll_dispatch_order")).isZero(); assertThat(count("ll_dispatch_item")).isZero(); assertThat(count("ll_dispatch_pda_task")).isZero(); assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH"); } - var first = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L)))); - assertThat(first.actions().canRemovePallet()).isFalse(); + var first = inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L)))); + assertThat(dispatch.items(aggregateFixture.actor,first.dispatchNo()).items()).allSatisfy(i->assertThat(i.remove().allowed()).isFalse()); assertThat(count("ll_dispatch_item")).isEqualTo(2); assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("PRE_DISPATCH_CREATED"); - assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L))))) + assertThatThrownBy(() -> inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L))))) .isInstanceOf(ResponseStatusException.class); - run(() -> dispatch.cancel(first.dispatchNo())); + run(() -> cancel(first.dispatchNo())); assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH"); - var second = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L)))); + var second = inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L)))); assertThat(second.dispatchNo()).isNotEqualTo(first.dispatchNo()); assertThat(count("ll_dispatch_order")).isEqualTo(2); } @@ -288,30 +289,31 @@ class DispatchRepairWorkflowTest { void mixedPalletUsesBothActualSourcesForBindingAndFreight(String displayInbound) throws Exception { seedPendingOutbound(); db.update("UPDATE ll_dispatch_item SET inbound_code=?,package_count=5 WHERE id=4", displayInbound); - db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(80,'C',81,'IN-NORMAL'),(82,'C',83,'IN-EXT')"); - db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages) VALUES(84,'C',80,501,'P-501','WH-1',3),(85,'C',82,501,'P-501','WH-1',2)"); - db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code) VALUES(81,'C','LM-NORMAL','IN-NORMAL'),(83,'C','LM-EXT','IN-EXT')"); + db.update("UPDATE ll_dispatch_order SET package_count=5 WHERE id=1");db.update("UPDATE ll_fleet_trip_dispatch SET package_count=5 WHERE dispatch_id=1"); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(80,'101',81,'IN-NORMAL'),(82,'101',83,'IN-EXT')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages) VALUES(84,'101',80,501,'P-501','9',3),(85,'101',82,501,'P-501','9',2)"); + db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code) VALUES(81,'101','LM-NORMAL','IN-NORMAL'),(83,'101','LM-EXT','IN-EXT')"); // 该测试同时校验客户隔离,不能把同物理托盘编号的他客库存算进来。 - db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(86,'OTHER',87,'FOREIGN')"); - db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(88,'OTHER',86,501,'WH-1',99)"); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(86,'202',87,'FOREIGN')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(88,'202',86,501,'9',99)"); db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)"); - var realCargo = mapper(DispatchFeeCargoMapper.class).list("C", 1); + var realCargo = mapper(DispatchFeeCargoMapper.class).list("101", 1); assertThat(realCargo).extracting(row -> row.inboundCode()).containsExactly("IN-NORMAL", "IN-EXT"); assertThat(realCargo).extracting(row -> row.packages()).containsExactly(3L, 2L); - var sources = inTransaction(() -> fleet.sourceCargo("DP-1")); + var sources = inTransaction(() -> fleet.sourceCargo("DP-1","101")); assertThat(sources).extracting(FleetDispatchGateway.SourceCargo::inboundCode) .containsExactlyInAnyOrder("IN-NORMAL", "IN-EXT"); assertThat(sources.stream().mapToInt(FleetDispatchGateway.SourceCargo::packages).sum()).isEqualTo(5); seedExternalBooking(); var reservations = new FleetBookingReservations(mapper(FleetBookingMapper.class), json); - var trip = new FleetTripEntity(); trip.setId(91L); trip.setTripNo("TR-1"); + var trip=mapper(FleetTripMapper.class).selectById(aggregateFixture.repository.find("DP-1").getTripId()); run(() -> reservations.bindIncoming(trip, - new FleetDispatchGateway.Snapshot(1L, "DP-1", "C", "WH-1", 5), sources, "OP")); + new FleetDispatchGateway.Snapshot(1L, "DP-1", "9", 5), "101", sources, "OP")); assertThat(text("ll_fleet_external_booking", "status", 90)).isEqualTo("BOUND"); - db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(91,'TR-1',1,'DP-1','C')"); + assertThat(mapper(FleetTripDispatchMapper.class).findByDispatchNo("DP-1").getTripId()).isEqualTo(trip.getId()); var freight = new FleetBookingFreight(mapper(FleetBookingMapper.class), mapper(FleetTripDispatchMapper.class), reservations, json); - var preview = inTransaction(() -> freight.preview(1L, "DP-1", "C", sources)); + var preview = inTransaction(() -> freight.preview(1L, "DP-1", "101", sources)); assertThat(preview.requiresDeliveryFee()).isTrue(); assertThat(preview.quotes()).singleElement().satisfies(quote -> { assertThat(quote.bookingNo()).isEqualTo("FB-EXT"); @@ -330,13 +332,13 @@ class DispatchRepairWorkflowTest { db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2"); db.update("UPDATE ll_dispatch_item SET source_stage='RETURN_RELABEL',source_business_type='RELABEL_ORDER',source_business_no='RL-1',inbound_code='RL-1' WHERE id=4"); db.update("UPDATE ll_outbound_pallet_item SET inbound_code='RL-1' WHERE id=5"); - db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,delivery_order_no,outbound_order_no,package_count,overseas_warehouse_id) VALUES(100,'C','LM-RL','RL-1','DL-1','OUT-1',2,'WH-1')"); - db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(104,'C','RL-1',2)"); - db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,carton_mark,pallet_code,overseas_warehouse_id,status,relabel_no,total_quantity) VALUES(101,'C','BOX-RL','P-501','WH-1','OUTBOUNDED','RL-1',6)"); - db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')"); - db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,6)"); - db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages,product_quantity) VALUES(105,'C',103,100,1,6)"); - var relations = inTransaction(() -> exceptions.returnCandidates("C", "DL-1")); + db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,delivery_order_no,outbound_order_no,package_count,overseas_warehouse_id) VALUES(100,'101','LM-RL','RL-1','DL-1','OUT-1',2,'9')"); + db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(104,'101','RL-1',2)"); + db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,carton_mark,pallet_code,overseas_warehouse_id,status,relabel_no,total_quantity) VALUES(101,'101','BOX-RL','P-501','9','OUTBOUNDED','RL-1',6)"); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,status,warehouse_id) VALUES(102,1,'COMPLETED','9')"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'101',102,4,101,1,6)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,return_carton_id,order_id,packages,product_quantity) VALUES(105,'101',103,101,100,1,6)"); + var relations = inTransaction(() -> exceptions.returnCandidates("101", "DL-1")); assertThat(relations).hasSize(1); assertThat(relations.get(0).returnSources()).singleElement().satisfies(source -> { var actual = json.valueToTree(source); @@ -349,11 +351,14 @@ class DispatchRepairWorkflowTest { /** 准备统一未拣货排车、出库及派送关系,避免用桩伪造取消资格。 */ private void seedPendingOutbound() { - db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,overseas_warehouse_id,status,package_count,picked_package_count,shipped_package_count) VALUES(1,'C','DP-1','WH-1','PENDING_DEPARTURE',2,0,0)"); - db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,outbound_type,status,package_count) VALUES(2,'C','OUT-1',1,'DISPATCH_OUTBOUND','WAIT_OUTBOUND',2)"); - db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count) VALUES(3,'C','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2)"); - db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'C',1,501,'P-501','WH-1',2,'FBA','ONT8')"); - db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'C',2,501,'P-501',2,0,'WAIT_PICK','ONT8')"); + var shell=inTransaction(()->aggregateFixture.fleet.createDispatchShell("OP",new FleetExecutionModels.DispatchShellRequest(1,"DP-1",9,"seed-physical-trip","b".repeat(64),null,null,null,null))); + db.update("INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,dispatch_review_json,loading_review_json,resources_json,item_count,pallet_count,package_count,picked_package_count,shipped_package_count) VALUES(1,'DP-1',?,'9','ACTIVE','BEFORE_LOADING',0,0,?,'DRAFT',0,?,?,?,1,1,2,0,0)",shell.tripId(),"c".repeat(64),"{\"status\":\"NOT_SUBMITTED\",\"reviewRound\":0}","{\"status\":\"NOT_SUBMITTED\",\"reviewRound\":0}","{\"vehicle\":null,\"driver\":null}"); + run(()->aggregateFixture.fleet.linkDispatchShell("OP",1,"DP-1",shell.tripNo())); + db.update("UPDATE ll_fleet_trip_dispatch SET package_count=2 WHERE dispatch_id=1"); + db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,dispatch_no,outbound_type,status,package_count,pallet_count) VALUES(2,'101','OUT-1',1,'DP-1','DISPATCH_OUTBOUND','WAIT_OUTBOUND',2,1)"); + db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count,pallet_count) VALUES(3,'101','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2,1)"); + db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'101',1,501,'P-501','9',2,'FBA','ONT8')"); + db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'101',2,501,'P-501',2,0,'WAIT_PICK','ONT8')"); } /** 固定真实外接实收两箱及约定金额,后续绑定和报价核对均运行生产逻辑。 */ @@ -361,26 +366,55 @@ class DispatchRepairWorkflowTest { var cargo = new FleetBookingModels.Cargo("FBA:ONT8", "外接货", 2, 1, BigDecimal.TEN, BigDecimal.ONE, "PALLET", new BigDecimal("60.00"), "CNY", null, null, null); var receipt = new FleetBookingModels.AcceptedReceipt(new FleetBookingIncomingGateway.Evidence( - "WT-EXT", "IN-EXT", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-v1", + "WT-EXT", "IN-EXT", "9", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-v1", List.of(new FleetBookingIncomingGateway.Pallet("501", "P-501", 2, BigDecimal.TEN, BigDecimal.ONE)), 2), List.of(new FleetBookingModels.PalletSpaces("501", 1, null, null)), "OP", Instant.parse("2026-10-01T00:00:00Z")); // 来货业务编号和库存入仓码是两个标识,必须经真实来货登记关联,不能混用。 - db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no,inbound_code,status) VALUES(93,'C','WT-EXT','IN-EXT','READY_FOR_DISPATCH')"); - db.update("INSERT INTO ll_fleet_external_booking(id,trip_id,trip_no,booking_no,incoming_no,customer_id,destination_key,status,agreed_amount,record_version,cargo_json,receipt_json) VALUES(90,91,'TR-1','FB-EXT','WT-EXT','C','FBA:ONT8','RECEIVED',60,1,?,?)", - json.writeValueAsString(cargo), json.writeValueAsString(receipt)); + db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no,inbound_code,status) VALUES(93,'101','WT-EXT','IN-EXT','READY_FOR_DISPATCH')"); + var trip=mapper(FleetTripMapper.class).selectById(aggregateFixture.repository.find("DP-1").getTripId()); + db.update("INSERT INTO ll_fleet_external_booking(id,trip_id,trip_no,booking_no,incoming_no,customer_id,destination_key,status,agreed_amount,record_version,cargo_json,receipt_json) VALUES(90,?,?,'FB-EXT','WT-EXT','101','FBA:ONT8','RECEIVED',60,1,?,?)", + trip.getId(),trip.getTripNo(),json.writeValueAsString(cargo), json.writeValueAsString(receipt)); } /** 构造来源输入;真实门禁仍由DispatchService及数据库锁决定。 */ private DispatchPalletCandidate candidate(long pallet) { - return new DispatchPalletCandidate().warehousePalletId(pallet).palletNo("P-" + pallet) - .customerId("C").overseasWarehouseId("WH-1").sourceStage("RECEIVED") + return new DispatchPalletCandidate().warehousePalletId(pallet).orderId(9000+pallet).palletNo("P-" + pallet) + .customerId("101").overseasWarehouseId("9").sourceStage("RECEIVED") .destinationType("FBA").destinationWarehouseCode("ONT8").fbaWarehouseCode("ONT8") .packageCount(2).grossWeightKg(BigDecimal.TEN).volumeCbm(BigDecimal.ONE); } + /** 同一申请两托共享真实履约订单,原入仓链保留在各自明细的冻结字段。 */ + private DispatchPalletCandidate relabelCandidate(long pallet) { + return candidate(pallet).orderId(9501L).orderNo("LM-RL").orderRefs("RL-1").inboundCode("RL-1").packageCount(1).sourceStage("RELABEL"); + } + /** 保留整单换标申请入口,不直接调用私有校验。 */ - private DispatchRequests.PreArrange relabel(List pallets) { - return new DispatchRequests.PreArrange(pallets, null, null, null, null, "RL-1"); + private DispatchCommands.Create relabel(List pallets) { + var cargo=pallets.stream().map(pallet->{var candidate=relabelCandidate(pallet); + return new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",pallet,sourceRules.wholeSourceVersion(candidate,"RELABEL:RL-1:"+pallet,false));}).toList(); + return create(cargo); + } + /** 使用当前公开整车创建DTO,不恢复旧客户预排接口。 */ + private DispatchCommands.Create create(List cargo) { + return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of()); + } + /** 重放固定原版本及原键,取消旧单不会取得或释放新单占用。 */ + private void cancel(String number) { + var request=cancellationCommands.computeIfAbsent(number,key->new DispatchCommands.ReasonCommand(aggregateFixture.repository.find(number).getRecordVersion(),"cancel-"+number,"取消原运输安排")); + dispatch.cancel(aggregateFixture.actor,number,request); + } + /** 候选聚合在边界固定,真实订单/计划根/库存和摘要仍由当前提供者锁定验证。 */ + private DispatchCommands.CargoSelection seedPlanSource() { + db.update("INSERT INTO ll_order(id,customer_id,order_no,detained) VALUES(9900,'101','LM-PLAN',0)"); + db.update("INSERT INTO ll_inbound_plan(id,customer_id,overseas_warehouse_id,source_order_id) VALUES(900,'101','9',9900)"); + db.update("INSERT INTO ll_inbound_plan_pallet(id,customer_id,plan_id,pallet_no,warehouse_pallet_id) VALUES(900,'101',900,'P-501',501)"); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_code) VALUES(9500,'101','IN-PLAN')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version) VALUES(9500,'101',9500,501,'P-501','9',2,0,0,0,0)"); + db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,order_id,warehouse_pallet_id,inventory_position_id,received_packages,inbound_code) VALUES(9500,'101',9900,501,9500,2,'IN-PLAN')"); + var row=candidate(501).planId(900L).planPalletId(900L).orderId(9900L).inboundCode("IN-PLAN"); + doReturn(List.of("101")).when(candidates).findCustomerScopesByPlanPalletIds(List.of(900L));doReturn(List.of(row)).when(candidates).readSelected("101",List.of(900L)); + return new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,900L,null,null,501L,sourceRules.wholeSourceVersion(row,"PLAN:900",false)); } /** 按类型注入实际依赖;未用到的外部能力保持未装配,调用时显式失败。 */ 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 index f82ea5eed..3972a16c1 100644 --- 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 @@ -1,868 +1,61 @@ package com.baiyue.lastleg.dispatch.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import com.baiyue.lastleg.dispatch.entity.DispatchDraft; -import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; -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.DispatchBolMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; -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.mapper.*; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -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 com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.service.*; +import org.junit.jupiter.api.*; 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.assertTrue; -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.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class DispatchServiceTest { - /** 历史取消出库不阻止取消原排车,重放不影响后续占用。 */ - @Test void cancelledOutboundAllowsOriginalDispatchCancellationAndPreservesAppointmentHistory() { - var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("100"); - dispatch.setDispatchNo("DP-OLD"); dispatch.setStatus("PENDING_DEPARTURE"); - var outbound = new com.baiyue.lastleg.delivery.entity.OutboundOrder(); outbound.setStatus("CANCELLED"); - var item = item(11L, "ONT8"); - when(orders.lockGlobalByDispatchNo("DP-OLD")).thenReturn(dispatch); - when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - var allocations = mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); - ReflectionTestUtils.setField(service, "appointmentAllocations", allocations); - service.cancel("DP-OLD"); service.cancel("DP-OLD"); - assertEquals("CANCELLED", dispatch.getStatus()); - verify(allocations, never()).removeItem(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); - verify(orders).updateById(dispatch); + final DispatchService service=new DispatchService(); + final DispatchSourceRepository sources=mock(DispatchSourceRepository.class); + final DispatchCandidateMapper candidates=mock(DispatchCandidateMapper.class); + final DispatchDraftMapper drafts=mock(DispatchDraftMapper.class); + final RelabelApplicationMapper relabels=mock(RelabelApplicationMapper.class); + final RelabelPalletMeasurementService measurements=mock(RelabelPalletMeasurementService.class); + @BeforeEach void setup(){ + ReflectionTestUtils.setField(service,"wholeSources",sources);ReflectionTestUtils.setField(service,"wholeHashes",new DispatchCommandHasher(new com.fasterxml.jackson.databind.ObjectMapper())); + ReflectionTestUtils.setField(service,"candidates",candidates);ReflectionTestUtils.setField(service,"drafts",drafts); + ReflectionTestUtils.setField(service,"relabelApplications",relabels);ReflectionTestUtils.setField(service,"relabelMeasurements",measurements); + ReflectionTestUtils.setField(service,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(service,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class)); + var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + // 本用例只验证完整选择的早期拒绝;实际贡献/HOLD由持久化主链覆盖。 + when(facts.snapshot(anyString(),anyList())).thenReturn(new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"CONTROLLED",true,List.of())); + ReflectionTestUtils.setField(service,"relabelSources",facts); } - - /** 普通换标部分选择与重复选择均在创建排车前拒绝。 */ - @Test void ordinaryRelabelRequiresTheWholeLockedApplicationBeforeAnyMutation() { - var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class); - ReflectionTestUtils.setField(service, "relabelApplications", relabels); - ReflectionTestUtils.setField(service, "relabelBindingGuard", mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class)); - var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication(); - application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); - application.setDeliveryMethod("TRUCK"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); - when(relabels.lockNo("100", "RL-1")).thenReturn(application); - when(candidates.relabelAvailable("100", null, "RL-1")).thenReturn(List.of(candidate(10L, 900L, "ONT8"), candidate(11L, 901L, "ONT8"))); - assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP", - new DispatchRequests.PreArrange(List.of(900L), null, null, null, null, "RL-1"))); - assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP", - new DispatchRequests.PreArrange(List.of(900L, 900L), null, null, null, null, "RL-1"))); - verify(orders, never()).insert(any(DispatchOrder.class)); - verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); - verify(candidates, never()).markRelabelPreDispatched(any(), any()); + @Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){ + assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources); } - - @Test void detailReturnsEveryOriginalBatchSourceWithoutLegacyAppointmentNumber() { - var dispatch=new DispatchOrder();dispatch.setId(1L);dispatch.setCustomerId("C");dispatch.setDispatchNo("DP-1"); - when(orders.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch); - var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);ReflectionTestUtils.setField(service,"appointmentAllocations",allocations); - var references=List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:1","AP-1","C", - List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:10","FBA:ONT8",40))), - new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:2","AP-2","C", - List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:11","FBA:ONT8",20)))); - when(allocations.references(List.of(1L))).thenReturn(java.util.Map.of(1L,references)); - var result=service.platformDetail("DP-1");assertNull(result.fbaAppointmentNo());assertEquals(references,result.appointmentBatches()); - verify(allocations).references(List.of(1L)); + @Test void wholeCandidatesPreserveActualCustomersAndDoNotChooseAHeaderOwner(){ + var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null); + when(sources.availablePlans(query)).thenReturn(List.of(row(2,502,"102"),row(1,501,"101"))); + assertThat(service.wholeCandidates("operator",query)).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102"); } - @org.junit.jupiter.params.ParameterizedTest - @org.junit.jupiter.params.provider.NullAndEmptySource - @org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "UNKNOWN"}) - void directRelabelCannotResolveDispatchScopeOrBypassCandidateFiltering(String deliveryMethod) { - var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class); - ReflectionTestUtils.setField(service, "relabelApplications", relabels); - var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication(); - application.setCustomerId("100"); application.setStatus("READY_FOR_OUTBOUND"); - application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); application.setDeliveryMethod(deliveryMethod); - when(relabels.findAnyByRelabelNo("RL-1")).thenReturn(application); - when(relabels.lockNo("100", "RL-1")).thenReturn(application); - assertThrows(ResponseStatusException.class, () -> service.resolveCustomerScopeForRelabel("RL-1")); - assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP", - new DispatchRequests.PreArrange(List.of(1L), null, null, null, null, "RL-1"))); - verify(orders, never()).insert(any(DispatchOrder.class)); - verify(candidates, never()).relabelAvailable(any(), any(), any()); - verify(relabelMeasurements, never()).requireMeasured(any()); + @Test void duplicateAndPartialRelabelSelectionsAreRejectedBeforeMutation(){ + var app=new RelabelApplication();app.setCustomerId("101");app.setRelabelNo("RL-1");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("TRUCK");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + when(sources.relabelOwner("RL-1")).thenReturn("101");when(relabels.lockNo("101","RL-1")).thenReturn(app); + when(candidates.relabelAvailable("101",null,"RL-1")).thenReturn(List.of(relabelRow(501),relabelRow(502))); + var selection=new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",501L,"version"); + assertThatThrownBy(()->service.lockWholeSources("9",List.of(selection))).hasMessageContaining("完整选择"); + assertThatThrownBy(()->service.lockWholeSources("9",List.of(selection,selection))).hasMessageContaining("重复选择"); + verify(candidates,never()).markRelabelPreDispatched(any(),any()); } - - @Mock DispatchOrderMapper orders; - @Mock DispatchItemMapper items; - @Mock DispatchBolMapper bolLines; - @Mock DispatchPalletLockMapper locks; - @Mock DispatchCandidateMapper candidates; - @Mock com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; - @Mock DispatchDraftMapper drafts; - @Mock DispatchDraftContainerMapper draftContainers; - @Mock DispatchPdaTaskMapper pdaTasks; - @Mock LastlegOrderMapper tailOrders; - @Mock OutboundOrderMapper outboundOrders; - @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; - @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; - @Mock ReturnRelabelDispatchStock returnRelabelStock; - @Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements; - private DispatchService service; - - @BeforeEach - void setUp() { - service = new DispatchService(); - ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); - ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class)); - ReflectionTestUtils.setField(service, "relabelMeasurements", relabelMeasurements); - ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow); - ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); - ReflectionTestUtils.setField(service, "returnRelabelStock", returnRelabelStock); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "bolLines", bolLines); - ReflectionTestUtils.setField(service, "locks", locks); - ReflectionTestUtils.setField(service, "candidates", candidates); - ReflectionTestUtils.setField(service, "returnCandidates", returnCandidates); - ReflectionTestUtils.setField(service, "drafts", drafts); - ReflectionTestUtils.setField(service, "draftContainers", draftContainers); - ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); - ReflectionTestUtils.setField(service, "tailOrders", tailOrders); - ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); - ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); - lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); - lenient().when(bolLines.findByDispatchOrder(any(), any())).thenReturn(List.of()); - lenient().when(tailOrders.selectList(any())).thenReturn(List.of()); + @Test void nonTruckRelabelCannotReachMeasurementsOrCargoMutation(){ + var app=new RelabelApplication();app.setCustomerId("101");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("EXPRESS");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + when(sources.relabelOwner("RL-1")).thenReturn("101");when(relabels.lockNo("101","RL-1")).thenReturn(app); + // 当前生产者SQL已过滤非TRUCK;消费方读不到来源时在加锁和测量前拒绝。 + assertThatThrownBy(()->service.lockWholeSources("9",List.of(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",501L,"v")))).hasMessageContaining("来源已变化"); + verifyNoInteractions(measurements);verify(candidates).relabelAvailable("101",null,"RL-1");verify(candidates,never()).markRelabelPreDispatched(any(),any()); } - - @Test - void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() { - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - LastlegOrder held = new LastlegOrder(); - held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true); - when(tailOrders.selectList(any())).thenReturn(List.of(held)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, - null, null, null))); - - assertEquals(409, exception.getStatusCode().value()); - assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason()); - verify(orders, never()).insert(any(DispatchOrder.class)); - } - - @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"))); - when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(item(11L, "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()); - ArgumentCaptor pdaTask = ArgumentCaptor.forClass(DispatchPdaTask.class); - verify(pdaTasks).insert(pdaTask.capture()); - assertEquals(dispatch.getValue().getId(), pdaTask.getValue().getDispatchOrderId()); - assertEquals("WH-1", pdaTask.getValue().getWarehouseId()); - assertEquals("PDA_PENDING", pdaTask.getValue().getStatus()); - assertEquals(1, dispatch.getValue().getPalletCount()); - verify(locks).insert(any(DispatchPalletLock.class)); - verify(items).insert(any(DispatchItem.class)); - } - - @Test - void preArrangeBuildsCommaSeparatedDistinctFbaRouteFromAssignedPallets() { - when(candidates.lockSelected("100", List.of(10L, 11L, 12L))).thenReturn(List.of( - candidate(10L, null, "ONT8"), candidate(11L, null, "ABE4"), candidate(12L, null, "ONT8"))); - when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of( - item(21L, "ONT8"), item(22L, "ABE4"), item(23L, "ONT8"))); - - DispatchResponse result = service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L, 11L, 12L), null, null, null)); - - assertEquals("ONT8,ABE4", result.route()); - ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); - verify(orders).insert(dispatch.capture()); - assertEquals("ONT8,ABE4", dispatch.getValue().getRoute()); - } - - @Test - void movingPalletIntoDraftImmediatelyCreatesExpiringExclusiveLock() { - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - - service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1")); - - ArgumentCaptor lock = ArgumentCaptor.forClass(DispatchPalletLock.class); - verify(locks).insert(lock.capture()); - assertEquals("draft-1", lock.getValue().getDraftToken()); - assertEquals("7", lock.getValue().getOperatorId()); - ArgumentCaptor group = ArgumentCaptor.forClass(DispatchDraftContainer.class); - verify(draftContainers).insert(group.capture()); - assertNotNull(group.getValue().getId()); - assertEquals(30, group.getValue().getCapacity()); - assertEquals(group.getValue().getId(), lock.getValue().getDraftContainerId()); - assertNotNull(lock.getValue().getExpiresAt()); - assertEquals(null, lock.getValue().getDispatchOrderId()); - verify(locks).clearInactiveActiveKey("100", "PLAN:10"); - } - - @Test - void movingVolumeOnlyPlanPalletIntoDraftCreatesLock() { - when(candidates.lockSelected("100", List.of(10L))) - .thenReturn(List.of(candidate(10L, null, "ONT8", 0))); - - service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1")); - - verify(locks).insert(any(DispatchPalletLock.class)); - } - - private DispatchDraftContainer existingDraftGroup() { - DispatchDraft draft = new DispatchDraft(); - draft.setId(20L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); - when(drafts.lockActive("draft-1", "7")).thenReturn(draft); - DispatchDraftContainer group = new DispatchDraftContainer(); - group.setId(21L); group.setDraftId(20L); group.setDraftToken("draft-1"); - return group; - } - - @Test void omittedGroupReusesTheOnlyGroupAndPersistsItsIdentity() { - var group = existingDraftGroup(); - when(draftContainers.findByToken("draft-1")).thenReturn(List.of(group)); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1")); - var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); - verify(locks).insert(lock.capture()); - assertEquals(21L, lock.getValue().getDraftContainerId()); - verify(draftContainers, never()).insert(any(DispatchDraftContainer.class)); - } - - @Test void omittedGroupReplayDoesNotCreateAnotherGroupOrLock() { - var group = existingDraftGroup(); - when(draftContainers.findByToken("draft-1")).thenReturn(List.of(group)); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - var lock = new DispatchPalletLock(); lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setDraftContainerId(21L); - when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); - service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1")); - verify(draftContainers, never()).insert(any(DispatchDraftContainer.class)); - verify(locks, never()).insert(any(DispatchPalletLock.class)); - } - - @Test void multipleGroupsRequireExplicitSelectionWithoutCreatingAnotherGroup() { - var group = existingDraftGroup(); - var second = new DispatchDraftContainer(); second.setId(22L); - when(draftContainers.findByToken("draft-1")).thenReturn(List.of(group, second)); - assertEquals(400, assertThrows(ResponseStatusException.class, () -> - service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"))).getStatusCode().value()); - verify(draftContainers, never()).insert(any(DispatchDraftContainer.class)); - verify(locks, never()).insert(any(DispatchPalletLock.class)); - } - - @Test void replayCannotSilentlyMoveAPalletToAnotherGroup() { - var group = existingDraftGroup(); - when(draftContainers.selectById(21L)).thenReturn(group); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - var lock = new DispatchPalletLock(); lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setDraftContainerId(22L); - when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); - assertEquals(409, assertThrows(ResponseStatusException.class, () -> - service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1", 21L))).getStatusCode().value()); - assertEquals(22L, lock.getDraftContainerId()); - verify(locks, never()).insert(any(DispatchPalletLock.class)); - } - - @Test - void preArrangeAcceptsVolumeOnlyPlanPallet() { - when(candidates.lockSelected("100", List.of(10L))) - .thenReturn(List.of(candidate(10L, null, "ONT8", 0))); - - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, - null, null, null)); - - ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); - verify(orders).insert(dispatch.capture()); - assertEquals(0, dispatch.getValue().getPackageCount()); - verify(items).insert(any(DispatchItem.class)); - } - - @Test - void preArrangeRejectsPalletWithoutResolvedOverseasWarehouseWithPreciseReason() { - DispatchPalletCandidate unresolved = new DispatchPalletCandidate(1L, 10L, 20L, 2L, - null, null, "LMO1", "LMO1", "RK1", "LIP1-P001", "ONT8", "ONT8", "ONT8", "ONT8", - 12, new BigDecimal("1.2000"), "RECEIVED"); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(unresolved)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); - - assertEquals("所选托盘中存在尚未关联海外仓的托盘", exception.getReason()); - verify(orders, never()).insert(any(DispatchOrder.class)); - } - - @Test - void preArrangeRejectsPalletWithoutFbaWarehouse() { - DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L, - "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001", - null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED"); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); - - assertEquals(409, exception.getStatusCode().value()); - assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason()); - verify(orders, never()).insert(any(DispatchOrder.class)); - } - - @Test - void removingPalletFromOwnedDraftImmediatelyReleasesLock() { - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); - when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); - - service.unlockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1")); - - assertEquals("RELEASED", lock.getStatus()); - assertEquals(null, lock.getActiveKey()); - verify(locks).updateById(lock); - } - - @Test - void preArrangeKeepsVirtualVehicleAndItsLocksEditableUntilOutboundExists() { - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); - DispatchDraft draft = new DispatchDraft(); - draft.setId(31L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE"); - when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); - when(drafts.lockActive("draft-1", "7")).thenReturn(draft); - - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, "draft-1")); - - assertNotNull(lock.getDispatchOrderId()); - assertEquals("draft-1", lock.getDraftToken()); - assertEquals(21L, lock.getDraftContainerId()); - assertEquals("ACTIVE", draft.getStatus()); - assertNull(draft.getExpiresAt()); - verify(locks).updateById(lock); - verify(locks, never()).insert(any(DispatchPalletLock.class)); - verify(drafts).updateById(draft); - } - - @Test - void removingUnpickedPalletReleasesLockAndRefreshesDispatchTotals() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); - dispatch.setStatus(DispatchService.PENDING_DEPARTURE); dispatch.setPickedPackageCount(0); - DispatchItem removed = new DispatchItem(); - removed.setId(11L); removed.setPlanPalletId(10L); removed.setWarehousePalletId(20L); - DispatchItem remaining = new DispatchItem(); - remaining.setId(12L); remaining.setPlanPalletId(12L); remaining.setPackageCount(7); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setPlanPalletId(10L); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - when(items.lockById("100", 1L, 11L)).thenReturn(removed); - when(locks.selectList(any())).thenReturn(List.of(lock)); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(remaining)); - - DispatchResponse result = service.removeItem("PC1", 11L); - - assertEquals(1, result.palletCount()); - assertEquals(7, result.packageCount()); - assertEquals("RELEASED", lock.getStatus()); - verify(items).deleteById(11L); - verify(orders).updateById(dispatch); - } - - @Test - void activeDraftOnlyRestoresLocksStillOwnedByThatDraft() { - DispatchDraft draft = new DispatchDraft(); - draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE"); - DispatchDraftContainer container = new DispatchDraftContainer(); - container.setId(21L); container.setDraftToken("draft-1"); container.setCapacity(12); - DispatchPalletLock owned = new DispatchPalletLock(); - owned.setPlanPalletId(10L); owned.setDraftToken("draft-1"); owned.setDraftContainerId(21L); - when(drafts.findActiveByOperator("7")).thenReturn(List.of(draft)); - when(draftContainers.findByToken("draft-1")).thenReturn(List.of(container)); - when(locks.findActiveByDraftContainer("draft-1", 21L)).thenReturn(List.of(owned)); - - var result = service.activeDrafts("7"); - - assertEquals(List.of("10"), result.get(0).containers().get(0).palletIds()); - verify(locks).findActiveByDraftContainer("draft-1", 21L); - } - - @Test - void draftContainerOnlyAcceptsFifteenOrThirtyPalletVehicles() { - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.createDraftContainer("7", new DispatchRequests.DraftContainer("draft-1", 14))); - - assertEquals(409, exception.getStatusCode().value()); - assertEquals("车辆大小仅支持15托或30托", exception.getReason()); - verify(drafts, never()).lockActive(any(), any()); - verify(draftContainers, never()).insert(any(DispatchDraftContainer.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", List.of(1L), null, null, "INVALID")); - verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); - } - - @Test - void platformCandidatesQueryAcrossCustomersWithoutPlanScope() { - when(candidates.available(null, null, null, null, "RECEIVED", "draft-1")).thenReturn(List.of()); - - service.platformCandidates(null, null, "RECEIVED", "draft-1"); - - verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1"); - } - - @Test - void relabelSourceDoesNotRequireOrQueryAnInboundPlan() { - when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of()); - org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty()); - verify(candidates).relabelAvailable(null, "RL-1", null); - verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); - } - - @Test - void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() { - when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100")); - when(candidates.findCustomerScopesByPlanPalletIds(List.of(10L, 11L))).thenReturn(List.of("100")); - - assertEquals("100", service.resolveCustomerScopeForPlans(List.of(2L, 1L, 1L))); - assertEquals("100", service.resolveCustomerScopeForPallets(List.of(11L, 10L))); - } - - @Test - void rejectsSelectedPlansThatSpanCustomers() { - when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100", "200")); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.resolveCustomerScopeForPlans(List.of(1L, 2L))); - - assertEquals(409, exception.getStatusCode().value()); - } - - @Test - void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() { - assertEquals(List.of(), service.candidates(null, null, null, null, null, "draft-1")); - verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.preArrange(null, "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); - assertEquals(409, exception.getStatusCode().value()); - } - - @Test - void platformEditResolvesCustomerFromGlobalDispatchNumberAndPreservesStatus() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); - dispatch.setStatus(DispatchService.PENDING_DEPARTURE); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - DispatchItem ont8 = item(11L, "ONT8"); - DispatchItem abe4 = item(12L, "ABE4"); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8"))); - LocalDateTime departAt = LocalDateTime.of(2026, 9, 18, 10, 30); - DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, - departAt, null, null, null, LocalDateTime.now()); - - DispatchResponse result = service.update("PC1", request); - - assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); - assertEquals("ABC-1", result.vehiclePlate()); - assertEquals(departAt, result.departAt()); - assertEquals(departAt, dispatch.getDepartAt()); - assertEquals("ONT8,ABE4", result.route()); - assertEquals("ONT8,ABE4", dispatch.getRoute()); - verify(orders).updateById(dispatch); - verify(fleetBindings).requireUnbound(1L); - verify(items).findByDispatchOrder("100", 1L); - } - - @Test void boundDispatchCannotEditRemoveOrCancelIndependently() { - DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setStatus("PENDING_DEPARTURE"); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先解除车次关联")) - .when(fleetBindings).requireUnbound(1L); - assertThrows(ResponseStatusException.class, () -> service.update("PC1", null)); - assertThrows(ResponseStatusException.class, () -> service.removeItem("PC1", 2L)); - assertThrows(ResponseStatusException.class, () -> service.cancel("PC1")); - verify(orders, never()).updateById((DispatchOrder) any()); - org.mockito.Mockito.verifyNoInteractions(items, locks, outboundOrders); - } - - @Test - void platformEditRejectsAnExecutedDispatchWithoutWriting() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); - dispatch.setStatus("DEPARTED"); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, - null, null, null, null, null); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.update("PC1", request)); - - assertEquals(409, exception.getStatusCode().value()); - verify(orders, never()).updateById(any(DispatchOrder.class)); - } - - @Test - void detailUsesResolvedCustomerScopeAndLoadsItems() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); - dispatch.setCustomerId("200"); - dispatch.setDispatchNo("PC1"); - when(orders.findByDispatchNo("200", "PC1")).thenReturn(dispatch); - - service.detail("200", "PC1"); - - verify(items).findByDispatchOrder("200", 1L); - verify(bolLines).findByDispatchOrder("200", 1L); - } - - @Test - void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() { - DispatchOrder first = new DispatchOrder(); - first.setId(1L); first.setCustomerId("100"); first.setDispatchNo("PC1"); - DispatchOrder second = new DispatchOrder(); - second.setId(2L); second.setCustomerId("200"); second.setDispatchNo("PC2"); - Page resultPage = new Page<>(1, 20, 2); - resultPage.setRecords(List.of(first, second)); - when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage); - - var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null, null)); - - assertEquals(2, result.total()); - verify(items).findByDispatchOrder("100", 1L); - verify(items).findByDispatchOrder("200", 2L); - verify(bolLines, never()).findByDispatchOrder(any(), any()); - } - - @Test - void platformDetailUsesCustomerFromMatchedDispatch() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); - when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch); - - var expected = new DispatchResponse.BolLine("D2144", "D2144-112-1", "LBE1", "Amazon LBE1", - "NPHK2607030206", "FBA19HFHQCHB", "7UH3F1DW", 138); - when(bolLines.findByDispatchOrder("200", 1L)).thenReturn(List.of(expected)); - - var result = service.platformDetail("PC1"); - - verify(items).findByDispatchOrder("200", 1L); - verify(bolLines).findByDispatchOrder("200", 1L); - assertEquals(List.of(expected), result.bolLines()); - } - - @Test - void globalCancelIsIdempotentAndReleasesOnlyTheResolvedCustomerLocks() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); - dispatch.setStatus(DispatchService.PENDING_DEPARTURE); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setStatus("ACTIVE"); lock.setActiveKey("PALLET:1"); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - when(locks.selectList(any())).thenReturn(List.of(lock)); - - service.cancel("PC1"); - - assertEquals(DispatchService.CANCELLED, dispatch.getStatus()); - assertEquals("RELEASED", lock.getStatus()); - assertEquals(null, lock.getActiveKey()); - verify(locks).updateById(lock); - - service.cancel("PC1"); - verify(locks, org.mockito.Mockito.times(1)).selectList(any()); - } - - @Test - void receivedPalletUsesPhysicalPalletAsDispatchLockIdentity() { - DispatchPalletCandidate mergedInboundOrder = candidate(10L, 900L, "ONT8"); - DispatchPalletCandidate plannedOnly = candidate(11L, null, "ONT8"); - - assertEquals("RECEIVED:900", mergedInboundOrder.sourcePalletKey()); - assertEquals("PLAN:11", plannedOnly.sourcePalletKey()); - } - - @Test void returnedReceiptPreArrangementUsesItsOwnReferenceAndTheRealTargetPalletLock() throws Exception { - var candidate = candidate(10L, 900L, "ONT8", 2); - candidate.planId(null); candidate.planPalletId(null); candidate.returnReceiptId(701L); candidate.returnReceiptNo("RSR-701"); - when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(candidate)); - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(), null, null, null, null, null, List.of(701L))); - var item = ArgumentCaptor.forClass(DispatchItem.class); verify(items).insert(item.capture()); - assertEquals(null, item.getValue().getPlanPalletId()); assertEquals(701L, item.getValue().getReturnReceiptId()); - assertEquals(2, item.getValue().getPackageCount()); - var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); verify(locks).insert(lock.capture()); - assertEquals("RECEIVED:900", lock.getValue().getActiveKey()); assertEquals(701L, lock.getValue().getReturnReceiptId()); - verify(candidates, never()).lockSelected(any(), any()); - var json = new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(candidate); - assertEquals("RETURN_RECEIPT", json.path("sourceType").asText()); - assertEquals("RSR-701", json.path("returnReceiptNo").asText()); assertTrue(json.path("planPalletId").isNull()); - } - - @Test void plannedAndReturnedSourcesCreateOneDispatchWithBothReferencesAndTheirExactQuantities() { - var planned = candidate(10L, 900L, "ONT8", 3); - var returned = candidate(11L, 901L, "ONT8", 2); - returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701"); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned)); - when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned)); - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L))); - var dispatch = ArgumentCaptor.forClass(DispatchOrder.class); verify(orders).insert(dispatch.capture()); - assertEquals(5, dispatch.getValue().getPackageCount()); assertEquals(2, dispatch.getValue().getPalletCount()); - var inserted = ArgumentCaptor.forClass(DispatchItem.class); - verify(items, org.mockito.Mockito.times(2)).insert(inserted.capture()); - var normalItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() == null).findFirst().orElseThrow(); - var returnItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() != null).findFirst().orElseThrow(); - assertEquals(10L, normalItem.getPlanPalletId()); assertEquals(3, normalItem.getPackageCount()); - assertEquals(null, returnItem.getPlanPalletId()); assertEquals(701L, returnItem.getReturnReceiptId()); assertEquals(2, returnItem.getPackageCount()); - assertEquals(normalItem.getDispatchOrderId(), returnItem.getDispatchOrderId()); - var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); - verify(locks, org.mockito.Mockito.times(2)).insert(lock.capture()); - assertEquals(java.util.Set.of("RECEIVED:900", "RECEIVED:901"), lock.getAllValues().stream() - .map(DispatchPalletLock::getActiveKey).collect(java.util.stream.Collectors.toSet())); - } - - @Test void mixedSourcesOnTheSamePhysicalPalletAreRejectedBeforeAnyDispatchOrLockWrite() { - var planned = candidate(10L, 900L, "ONT8", 3); - var returned = candidate(11L, 900L, "ONT8", 2); - returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701"); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned)); - when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned)); - var failure = assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L)))); - assertEquals(409, failure.getStatusCode().value()); assertTrue(failure.getReason().contains("同一实物托盘")); - verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class)); - verify(locks, never()).insert(any(DispatchPalletLock.class)); verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); - } - - @Test - void twoLoadingGroupsCreateTwoIndependentDispatchesAndExactReplayDoesNotWriteAgain() throws Exception { - var fixture = twoGroupFixture(); - assertNotNull(DispatchService.class.getMethod("preArrangeDraft", String.class, - DispatchRequests.PreArrangeDraft.class).getAnnotation(Transactional.class)); - var result = service.preArrangeDraft("7", fixture.request()); - assertEquals(2, result.size()); - assertTrue(!result.get(0).dispatch().id().equals(result.get(1).dispatch().id())); - for (var group : result) { - assertEquals(2, group.dispatch().palletCount()); assertEquals(2, group.dispatch().packageCount()); - assertEquals("ABE4,ABE8", group.dispatch().route()); - assertEquals(2, group.dispatch().items().size()); - } - assertEquals(java.util.Set.of(10L, 11L), result.get(0).dispatch().items().stream() - .map(DispatchResponse.Item::planPalletId).collect(java.util.stream.Collectors.toSet())); - assertEquals(java.util.Set.of(12L, 13L), result.get(1).dispatch().items().stream() - .map(DispatchResponse.Item::planPalletId).collect(java.util.stream.Collectors.toSet())); - org.mockito.Mockito.clearInvocations(orders, items, locks, pdaTasks, drafts); - assertEquals(result, service.preArrangeDraft("7", fixture.request())); - verify(orders, never()).insert(any(DispatchOrder.class)); verify(orders, never()).updateById(any(DispatchOrder.class)); - verify(items, never()).insert(any(DispatchItem.class)); verify(locks, never()).updateById(any(DispatchPalletLock.class)); - verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); verify(drafts, never()).updateById(any(DispatchDraft.class)); - } - - @Test - void flatRequestCannotCollapseTwoOwnedDraftContainers() { - var fixture = twoGroupFixture(); - when(candidates.lockSelected("100", List.of(10L,11L,12L,13L))).thenReturn(fixture.candidates()); - assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L,11L,12L,13L),null,null,null,"draft-multi"))); - verify(orders, never()).insert(any(DispatchOrder.class)); - } - - @Test - void groupedInitialResponseUsesPersistedTimestampPrecisionForExactReplay() { - var fixture = twoGroupFixture(true); - var first = service.preArrangeDraft("7", fixture.request()); - assertEquals(123_000_000, first.get(0).dispatch().createdAt().getNano()); - assertEquals(first, service.preArrangeDraft("7", fixture.request())); - } - - @Test - void changedMembershipDuplicateSourceExpiredDraftAndCapacityFailBeforeCreatingAnyGroup() { - var fixture = twoGroupFixture(); - var swapped = new DispatchRequests.PreArrangeDraft("draft-multi", List.of( - new DispatchRequests.DraftGroup(21L,List.of(10L,12L),List.of()), - new DispatchRequests.DraftGroup(22L,List.of(11L,13L),List.of()))); - assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", swapped)); - var duplicated = new DispatchRequests.PreArrangeDraft("draft-multi", List.of( - new DispatchRequests.DraftGroup(21L,List.of(10L,11L),List.of()), - new DispatchRequests.DraftGroup(22L,List.of(10L,13L),List.of()))); - assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", duplicated)); - fixture.containers().get(1).setCapacity(1); - assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", fixture.request())); - fixture.containers().get(1).setCapacity(30); - fixture.draft().setExpiresAt(LocalDateTime.now().minusSeconds(1)); - assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", fixture.request())); - verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class)); - } - - @Test - void groupedPreArrangeRejectsUnsupportedStoredCapacityBeforeCreatingDispatches() { - var fixture = twoGroupFixture(); - for (Integer capacity : java.util.Arrays.asList(null, 0, 31, 500)) { - fixture.containers().get(0).setCapacity(capacity); - var error = assertThrows(ResponseStatusException.class, - () -> service.preArrangeDraft("7", fixture.request())); - assertEquals(409, error.getStatusCode().value()); - assertEquals("车辆大小仅支持15托或30托", error.getReason()); - } - verify(orders, never()).insert(any(DispatchOrder.class)); - verify(items, never()).insert(any(DispatchItem.class)); - } - - @Test - void groupedPreArrangeRejectsThirtyOnePalletsInThirtyPalletVehicle() { - twoGroupFixture(); - var palletIds = java.util.stream.LongStream.rangeClosed(100, 130).boxed().toList(); - var bound = palletIds.stream().map(id -> { - var lock = new DispatchPalletLock(); - lock.setPlanPalletId(id); - return lock; - }).toList(); - when(locks.lockActiveByDraftContainer("draft-multi", 21L)).thenReturn(bound); - var request = new DispatchRequests.PreArrangeDraft("draft-multi", - List.of(new DispatchRequests.DraftGroup(21L, palletIds, List.of()))); - var error = assertThrows(ResponseStatusException.class, - () -> service.preArrangeDraft("7", request)); - assertEquals(409, error.getStatusCode().value()); - assertEquals("装车分组超过车型托位容量", error.getReason()); - verify(orders, never()).insert(any(DispatchOrder.class)); - verify(items, never()).insert(any(DispatchItem.class)); - } - - @Test - void secondGroupFailureRollsBackFirstGroupInsertInTheSameTransaction() { - var fixture = twoGroupFixture(); - when(candidates.lockSelected("100",List.of(12L,13L))).thenReturn(List.of( - candidate(12L,912L,null,1),candidate(13L,913L,"ABE8",1))); - var ds = new org.h2.jdbcx.JdbcDataSource(); - ds.setURL("jdbc:h2:mem:dispatch-group-"+java.util.UUID.randomUUID()+";DB_CLOSE_DELAY=-1"); - var jdbc = new org.springframework.jdbc.core.JdbcTemplate(ds); - jdbc.execute("CREATE TABLE dispatch_probe(id BIGINT PRIMARY KEY)"); - org.mockito.Mockito.doAnswer(call -> { var row=(DispatchOrder)call.getArgument(0); - jdbc.update("INSERT INTO dispatch_probe VALUES(?)",row.getId()); - fixture.savedOrders().put(row.getId(), row);return 1; }).when(orders).insert(any(DispatchOrder.class)); - var transaction = new org.springframework.transaction.support.TransactionTemplate( - new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds)); - assertThrows(ResponseStatusException.class, () -> transaction.execute(status -> service.preArrangeDraft("7",fixture.request()))); - verify(orders).insert(any(DispatchOrder.class)); - assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM dispatch_probe",Integer.class)); - } - - private GroupFixture twoGroupFixture() { - return twoGroupFixture(false); - } - - private GroupFixture twoGroupFixture(boolean databaseTimestampPrecision) { - var draft = new DispatchDraft(); draft.setId(1L);draft.setDraftToken("draft-multi");draft.setOperatorId("7"); - draft.setStatus("ACTIVE");draft.setExpiresAt(LocalDateTime.now().plusMinutes(30)); - lenient().when(drafts.lockActive("draft-multi","7")).thenReturn(draft); - var containerList = new java.util.ArrayList(); - var allCandidates = new java.util.ArrayList(); - var savedOrders = new java.util.HashMap(); - var savedItems = new java.util.HashMap>(); - lenient().doAnswer(call -> { - var row=(DispatchOrder)call.getArgument(0); - if (databaseTimestampPrecision) { - row.setCreatedAt(row.getCreatedAt().withNano(123_456_000)); - var persisted = new DispatchOrder(); - org.springframework.beans.BeanUtils.copyProperties(row, persisted); - persisted.setCreatedAt(row.getCreatedAt().withNano(123_000_000)); - savedOrders.put(row.getId(), persisted); - } else savedOrders.put(row.getId(),row); - return 1; - }) - .when(orders).insert(any(DispatchOrder.class)); - lenient().when(orders.lockById(eq("100"),any())).thenAnswer(call -> savedOrders.get(call.getArgument(1))); - lenient().doAnswer(call -> {var row=(DispatchItem)call.getArgument(0);savedItems.computeIfAbsent(row.getDispatchOrderId(),k->new java.util.ArrayList<>()).add(row);return 1;}) - .when(items).insert(any(DispatchItem.class)); - lenient().when(items.findByDispatchOrder(eq("100"),any())).thenAnswer(call -> savedItems.getOrDefault(call.getArgument(1),new java.util.ArrayList<>())); - for(int i=0;i<2;i++) { - long containerId=21L+i, first=10L+2*i; - var c=new DispatchDraftContainer();c.setId(containerId);c.setDraftId(1L);c.setDraftToken("draft-multi");c.setCapacity(30);c.setSequenceNo(i+1);containerList.add(c); - var rows=List.of(candidate(first,900L+first,"ABE4",1),candidate(first+1,901L+first,"ABE8",1));allCandidates.addAll(rows); - var bound=new java.util.ArrayList(); - for(var row:rows) {var lock=new DispatchPalletLock();lock.setId(row.planPalletId());lock.setCustomerId("100"); - lock.setDraftToken("draft-multi");lock.setDraftContainerId(containerId);lock.setOperatorId("7"); - lock.setStatus("ACTIVE");lock.setPlanPalletId(row.planPalletId());lock.setSourcePalletKey(row.sourcePalletKey());bound.add(lock); - lenient().when(locks.lockActive("100",row.sourcePalletKey())).thenReturn(lock);} - lenient().when(locks.lockActiveByDraftContainer("draft-multi",containerId)).thenReturn(bound); - lenient().when(locks.findDispatchOrderIdByDraftContainer("draft-multi",containerId)).thenAnswer(call -> bound.get(0).getDispatchOrderId()); - lenient().when(candidates.findCustomerScopesByPlanPalletIds(List.of(first,first+1))).thenReturn(List.of("100")); - lenient().when(candidates.lockSelected("100",List.of(first,first+1))).thenReturn(rows); - } - lenient().when(draftContainers.findByToken("draft-multi")).thenReturn(containerList); - return new GroupFixture(draft,containerList,allCandidates,new DispatchRequests.PreArrangeDraft("draft-multi",List.of( - new DispatchRequests.DraftGroup(21L,List.of(10L,11L),List.of()),new DispatchRequests.DraftGroup(22L,List.of(12L,13L),List.of()))),savedOrders); - } - - private record GroupFixture(DispatchDraft draft,List containers, - List candidates,DispatchRequests.PreArrangeDraft request, - java.util.Map savedOrders) { } - - private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { - return candidate(planPalletId, warehousePalletId, destination, 12); - } - - private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination, - int packageCount) { - return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", - "LIP1-P001", destination, destination, destination, destination, packageCount, - new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); - } - - private DispatchItem item(long id, String fbaWarehouseCode) { - DispatchItem item = new DispatchItem(); - item.setId(id); - item.setFbaWarehouseCode(fbaWarehouseCode); - return item; + @Test void sourceIdentityCannotCombinePlanAndReturnReceipt(){ + assertThatThrownBy(()->DispatchService.wholeSourceKey(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,2L,null,501L,"v"))).hasMessageContaining("来源字段"); } + private static DispatchPalletCandidate row(long plan,long pallet,String customer){return new DispatchPalletCandidate().planPalletId(plan).warehousePalletId(pallet).customerId(customer).overseasWarehouseId("9").sourceStage("RECEIVED").packageCount(3).orderNo("ORDER-"+plan).palletNo("PALLET-"+pallet);} + /** 换标生产者没有计划托盘别名,始终提供真实申请业务号与实托。 */ + private static DispatchPalletCandidate relabelRow(long pallet){return row(0,pallet,"101").planPalletId(null).sourceStage("RELABEL").orderRefs("RL-1");} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java new file mode 100644 index 000000000..446d3e332 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 候选投影在此受控,来源订单/仓/库存版本/实收组成与锁序均经实际MyBatis查询。 */ +class DispatchWholeSourcePersistenceTest { + /** 同仓多客户先锁齐来源订单,随后稳定锁计划和库存,不以MIN展示订单替代组成。 */ + @Test void resolvesMultipleCustomersAndLocksAllSourceOrdersBeforeInventory() throws Exception { + var fixture=new Sources();var selections=fixture.selections();clearInvocations(fixture.sourceMapper); + var actual=fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selections)); + assertThat(actual).extracting(s->s.candidate().customerId()).containsExactly("101","202"); + var order=inOrder(fixture.sourceMapper);order.verify(fixture.sourceMapper).lockOrder("101",9001L);order.verify(fixture.sourceMapper).lockOrder("202",9002L); + order.verify(fixture.sourceMapper).lockPlanPallet("101",1L);order.verify(fixture.sourceMapper).stock("101",501L,true); + order.verify(fixture.sourceMapper).lockPlanPallet("202",2L);order.verify(fixture.sourceMapper).stock("202",502L,true); + } + /** 版本、实收组成、暂扣、物理身份和跨仓变化均不能沿旧选择继续排车。 */ + @Test void rejectsChangedFactsHeldSourcesForeignPalletAndWarehouse() throws Exception { + var fixture=new Sources();var selected=fixture.selections(); + fixture.f.db.update("UPDATE ll_inventory_position SET version=version+1,available_packages=available_packages+1 WHERE id=1"); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("真实可用库存来源"); + fixture.f.db.update("UPDATE ll_inventory_position SET version=1,available_packages=3 WHERE id=1"); + fixture.f.db.update("UPDATE ll_order SET detained=1 WHERE id=9002"); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("暂扣"); + fixture.f.db.update("UPDATE ll_order SET detained=0 WHERE id=9002"); + var forged=new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,null,null,999L,selected.get(0).sourceVersion()); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",List.of(forged)))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("实托身份"); + fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=4 WHERE id=1"); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本已变化"); + fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=3 WHERE id=1"); + fixture.f.db.update("UPDATE ll_inventory_position SET overseas_warehouse_id='99' WHERE id=1"); + assertThatThrownBy(()->fixture.rules.wholeSourceVersion(fixture.f.candidates.get(1L),"PLAN:1",false)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("起运仓不一致"); + } + + /** 实际来源表保持独立客户;只有复杂候选投影查询在本用例隔离。 */ + private static final class Sources { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchSourceMapper sourceMapper;final DispatchService rules=new DispatchService(); + /** 构造真实订单组成和库存版本,不能由候选响应替代这些数据库事实。 */ + Sources() throws Exception { + f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5); + f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0"); + f.db.execute("ALTER TABLE ll_order ADD overseas_warehouse_id VARCHAR(64)"); + f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)"); + f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,customer_id VARCHAR(64))"); + f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,pallet_no VARCHAR(128),overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT,version BIGINT)"); + for(String column:List.of("order_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT","warehouse_pallet_id BIGINT"))f.db.execute("ALTER TABLE ll_inbound_stock_receipt ADD "+column); + for(long id:List.of(1L,2L)) { + var row=f.candidates.get(id);f.db.update("INSERT INTO ll_order(id,customer_id,order_no)VALUES(?,?,?)",row.orderId(),row.customerId(),row.orderNo()); + f.db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?,?)",id,row.customerId(),"9",row.orderId());f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?,?)",id,id,row.palletNo(),row.warehousePalletId(),row.customerId()); + f.db.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?)",id,row.orderId(),row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?)",id,id); + f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount()); + f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId()); + } + var plans=mock(DispatchCandidateMapper.class); + when(plans.findCustomerScopesByPlanPalletIds(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return List.of(f.candidates.get(ids.get(0)).customerId());}); + when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List ids=call.getArgument(1);return List.of(f.candidates.get(ids.get(0)));}); + sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class)); + var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper); + ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); + var relabel=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + org.mockito.stubbing.Answer snapshot=call->{ + List pallets=call.getArgument(1); + var positions=f.db.query("SELECT * FROM ll_inventory_position ORDER BY id",(rs,n)->new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Position(rs.getLong("id"),rs.getString("customer_id"),rs.getLong("balance_id"),rs.getLong("pallet_id"),rs.getLong("available_packages"),0,0,0,0,rs.getLong("version"))).stream().filter(p->pallets.contains(p.warehousePalletId())).toList(); + return new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),positions,"ORDINARY",true,List.of()); + }; + when(relabel.snapshot(anyString(),anyList())).thenAnswer(snapshot);when(relabel.lockedSnapshot(anyString(),anyList())).thenAnswer(snapshot); + ReflectionTestUtils.setField(rules,"relabelSources",relabel);ReflectionTestUtils.setField(rules,"palletSerial",mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class)); + } + /** GET与写锁内复用同一真实事实摘要。 */ + List selections(){return f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->new DispatchCommands.CargoSelection( + DispatchTypes.SourceType.INBOUND_PLAN,entry.getKey(),null,null,entry.getValue().warehousePalletId(),rules.wholeSourceVersion(entry.getValue(),"PLAN:"+entry.getKey(),false))).toList();} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java index fbfd62f09..5eb5fcda0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java @@ -39,7 +39,7 @@ class ReturnDispatchCandidatePersistenceTest { assertThat(row.warehousePalletId()).isEqualTo(900); assertThat(row.grossWeightKg()).isNull(); assertThat(mapper.lockSelected("A", List.of(6L))).hasSize(1); assertThat(mapper.lockSelected("B", List.of(6L))).isEmpty(); - db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RECEIVED:900','ACTIVE',NULL,'another-draft')"); + db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RETURN:6','ACTIVE',NULL,'another-draft')"); assertThat(mapper.available("A", null, null, "this-draft")).isEmpty(); db.update("DELETE FROM ll_dispatch_pallet_lock"); db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java index 76b1eeaa5..92bbc2ca6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java @@ -63,8 +63,10 @@ class ExceptionOrderDispatchReturnSourceTest { ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); } - @Test - void returnsTheAttributedInventorySourceForDispatchDeliveryPicks() { + /** 普通入仓与没有普通入仓单的换标主单均按真实拣货归属返回仓位来源。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void returnsTheAttributedInventorySourceForDispatchDeliveryPicks(boolean relabelWithoutInbound) { DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(400L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1"); delivery.setOutboundNo("OUT-1"); delivery.setStatus("DELIVERED"); @@ -75,7 +77,7 @@ class ExceptionOrderDispatchReturnSourceTest { outboundItem.setId(301L); outboundItem.setCustomerId("1001"); outboundItem.setOutboundOrderId(300L); outboundItem.setWarehousePalletId(501L); outboundItem.setPalletNo("PLT-1"); outboundItem.setInboundCode("IN-1"); DispatchPdaTask task = new DispatchPdaTask(); - task.setId(201L); task.setCustomerId("1001"); task.setDispatchOrderId(200L); + task.setId(201L); task.setDispatchOrderId(200L); DispatchItem dispatchItem = new DispatchItem(); dispatchItem.setId(202L); dispatchItem.setCustomerId("1001"); dispatchItem.setDispatchOrderId(200L); dispatchItem.setWarehousePalletId(501L); @@ -94,11 +96,14 @@ class ExceptionOrderDispatchReturnSourceTest { when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery); when(lastMileOrders.selectList(any())).thenReturn(List.of(tail)); - when(inboundOrders.selectList(any())).thenReturn(List.of(inbound)); + if (relabelWithoutInbound) { + tail.setInboundCode(null);dispatchItem.setSourceStage("RELABEL"); + dispatchItem.setInboundCode("RL-1");outboundItem.setInboundCode("RL-1"); + } else when(inboundOrders.selectList(any())).thenReturn(List.of(inbound)); when(outboundOrders.findByNo("1001", "OUT-1")).thenReturn(outbound); when(outboundPalletItems.findByOutbound("1001", 300L)).thenReturn(List.of(outboundItem)); when(outboundPdaPicks.findByOutbound("1001", 300L)).thenReturn(List.of()); - when(dispatchPdaTasks.findByDispatch("1001", 200L)).thenReturn(task); + when(dispatchPdaTasks.findByDispatch(200L)).thenReturn(task); when(dispatchItems.findByDispatchOrder("1001", 200L)).thenReturn(List.of(dispatchItem)); when(dispatchPdaPicks.listTask("1001", 201L)).thenReturn(List.of(pick)); when(dispatchPdaPickOrders.listPick("1001", 203L)).thenReturn(List.of(attribution)); @@ -107,6 +112,8 @@ class ExceptionOrderDispatchReturnSourceTest { var candidates = service.returnCandidates("1001", "DL-1"); assertThat(candidates).hasSize(1); + assertThat(candidates.get(0).lastMileOrderId()).isEqualTo(101L); + if (relabelWithoutInbound) assertThat(candidates.get(0).inboundOrderId()).isNull(); assertThat(candidates.get(0).returnSources()).singleElement().satisfies(source -> { assertThat(source.outboundPalletItemId()).isEqualTo(301L); assertThat(source.inventoryPositionId()).isEqualTo(601L); @@ -130,7 +137,7 @@ class ExceptionOrderDispatchReturnSourceTest { outboundItem.setId(301L); outboundItem.setCustomerId("1001"); outboundItem.setOutboundOrderId(300L); outboundItem.setWarehousePalletId(501L); outboundItem.setPalletNo("PLT-1"); outboundItem.setInboundCode("IN-1"); DispatchPdaTask task = new DispatchPdaTask(); - task.setId(201L); task.setCustomerId("1001"); task.setDispatchOrderId(200L); + task.setId(201L); task.setDispatchOrderId(200L); DispatchItem dispatchItem = new DispatchItem(); dispatchItem.setId(202L); dispatchItem.setCustomerId("1001"); dispatchItem.setDispatchOrderId(200L); dispatchItem.setWarehousePalletId(501L); @@ -192,7 +199,7 @@ class ExceptionOrderDispatchReturnSourceTest { : "MULTI_REVERSED".equals(layout) ? List.of(otherItem, outboundItem) : List.of(outboundItem, otherItem); when(outboundPalletItems.findByOutbound("1001", 300L)).thenReturn(outboundSnapshots); when(outboundPdaPicks.findByOutbound("1001", 300L)).thenReturn(List.of()); - when(dispatchPdaTasks.findByDispatch("1001", 200L)).thenReturn(task); + when(dispatchPdaTasks.findByDispatch(200L)).thenReturn(task); when(dispatchItems.findByDispatchOrder("1001", 200L)).thenReturn(List.of(dispatchItem)); when(dispatchPdaPicks.listTask("1001", 201L)).thenReturn(List.of(pick, secondPick)); when(dispatchPdaPickOrders.listPick("1001", 203L)).thenReturn(List.of(attribution)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java index 818a8ca1e..a7181eb07 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java @@ -10,6 +10,9 @@ import com.baiyue.lastleg.exceptionorder.repository.ReturnCartonReturnRepository import com.baiyue.lastleg.returnreceipt.mapper.*; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; import com.baiyue.lastleg.dispatch.pda.mapper.*; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; @@ -32,10 +35,10 @@ class ReturnCartonReturnPersistenceTest { @BeforeEach void setup() throws Exception { var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data)); - var config=new MybatisConfiguration(); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class, ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class, - DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchOrderMapper.class,DispatchItemMapper.class, + DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class, DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class); mappers.forEach(config::addMapper); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); @@ -47,6 +50,7 @@ class ReturnCartonReturnPersistenceTest { table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType()))); db.execute("CREATE TABLE IF NOT EXISTS "+table.getTableName()+"("+String.join(",",columns)+")"); } + createAggregateTable(); db.execute("CREATE UNIQUE INDEX balance_scope ON ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code)"); db.execute("CREATE UNIQUE INDEX receipt_source ON ll_return_carton_stock_receipt(customer_id,source_pick_id,sku_code)"); db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(128),business_order_no VARCHAR(128),outbound_order_no VARCHAR(128),business_type VARCHAR(128),delta_packages BIGINT,source_event_id VARCHAR(128),occurred_at TIMESTAMP)"); @@ -59,9 +63,9 @@ class ReturnCartonReturnPersistenceTest { db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)"); db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')"); db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code) VALUES(5,'C',2,500,'RL-1','ONT8')"); - db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no) VALUES(10,'C','DP-1')"); - db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code,source_stage,overseas_warehouse_id) VALUES(4,'C',10,500,'RL-1','ONT8','RETURN_RELABEL','92')"); - db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id) VALUES(102,'C',10)"); + db.update("INSERT INTO ll_dispatch_order(id,dispatch_no,status,stage,record_version,cargo_revision,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,overseas_warehouse_id,departed_at) VALUES(10,'DP-1','ACTIVE','DELIVERING',0,0,1,1,1,1,1,'92',?)",LocalDateTime.now().minusHours(1)); + db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code,destination_type,package_count,source_stage,overseas_warehouse_id) VALUES(4,'C',10,500,'RL-1','ONT8','FBA',1,'RETURN_RELABEL','92')"); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,task_no,status,warehouse_id) VALUES(102,10,'PDA-DP-1','COMPLETED','92')"); db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,8)"); db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(105,'C',103,100,1)"); db.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id,approved_return_packages,received_packages) VALUES(9,'C',1,5,101,1,0)"); @@ -98,9 +102,8 @@ class ReturnCartonReturnPersistenceTest { /** 完成命令真实落账后才推进派送并供车次结案消费,同键重放不重复恢复库存。 */ @Test void realExceptionCompletionAndTripCompletionConsumeTheNativeReceipt() { db.update("UPDATE ll_exception_order SET exception_type='RETURN',delivery_no='DL-1',version=0 WHERE id=1"); - db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',status='RETURN_PENDING',package_count=1,version=0 WHERE id=3"); - db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=1,overseas_warehouse_id='92' WHERE id=10"); - db.update("UPDATE ll_outbound_order SET package_count=1 WHERE id=2"); + db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',overseas_warehouse_id='92',destination_type='FBA',destination_warehouse_code='ONT8',status='RETURN_PENDING',package_count=1,version=0 WHERE id=3"); + db.update("UPDATE ll_outbound_order SET package_count=1,overseas_warehouse_id='92' WHERE id=2"); db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no) VALUES(100,'C','LM-1','DL-1')"); var business=business(); var request=new ExceptionOrderRequests.Complete(0,"native-complete","实收原箱",List.of(999L),List.of(receipt())); @@ -109,9 +112,14 @@ class ReturnCartonReturnPersistenceTest { assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("RETURNED"); assertThat(tx.execute(s->business.complete("C","OP","EX-1",request)).replayed()).isTrue(); assertThat(db.queryForObject("SELECT SUM(packages) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(1); - var fleet=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(map(DispatchOrderMapper.class),map(DispatchPdaTaskMapper.class),map(OutboundOrderMapper.class),map(DeliveryOrderMapper.class),map(DispatchItemMapper.class),map(DispatchPdaPickMapper.class),mock(com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService.class),map(DeliveryOperationMapper.class)); + var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class); + var fleet=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter( + new DispatchAggregateRepository(map(DispatchAggregateMapper.class),map(DispatchItemMapper.class),map(DispatchReadMapper.class)), + map(OutboundOrderMapper.class),map(DeliveryOrderMapper.class),map(DeliveryOperationMapper.class), + new DispatchCargoSourceRepository(map(DispatchFeeCargoMapper.class)),workflow,new ObjectMapper().findAndRegisterModules()); var outcome=tx.execute(s->fleet.completion("DP-1",true)); assertThat(outcome.blockReason()).isNull();assertThat(outcome.outcomes()).singleElement().satisfies(row->{assertThat(row.returnedPackages()).isEqualTo(1);assertThat(row.remainingPackages()).isZero();}); + verifyNoInteractions(workflow); } /** 不制造入仓单,从真实换标主单经过请求校验、标记、受理、回仓直至派送结案。 */ @Test void nativeMasterWithoutInboundCanMarkAndCompleteAfterBeanValidation() throws Exception { @@ -191,6 +199,12 @@ class ReturnCartonReturnPersistenceTest { private ExceptionOrderRequests.ReturnReceipt receipt(){return new ExceptionOrderRequests.ReturnReceipt(5L,null,1,501L,"P-NEW",301L,"LOC-301",101L);} /** 根据实体类型建立测试表;正式DDL另由Schema合同验证。 */ private static String sqlType(Class type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(8000)";} + /** 当前整车使用独立聚合模型,测试表不引入旧客户头部或旧主单实体。 */ + private void createAggregateTable() { + var columns=Arrays.stream(DispatchAggregate.class.getDeclaredFields()).filter(f->!f.isSynthetic()&&!java.lang.reflect.Modifier.isStatic(f.getModifiers())) + .map(f->f.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT)+" "+sqlType(f.getType())+(f.getName().equals("id")?" PRIMARY KEY":"")).toList(); + db.execute("CREATE TABLE ll_dispatch_order("+String.join(",",columns)+")"); + } /** 获取隔离库的真实Mapper代理。 */ private T map(Class type){return session.getMapper(type);} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java index a3671f2cc..0673904d1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java @@ -22,7 +22,7 @@ class FleetHandoverPdaQueryTest { final FleetHandoverPdaQuery service = new FleetHandoverPdaQuery(bindings, trips, history, dispatches, handovers, json); @BeforeEach void setup() { - var binding = new FleetTripDispatch(); binding.setDispatchNo("PC-A"); binding.setCustomerId("CUSTOMER-A"); + var binding = new FleetTripDispatch(); binding.setDispatchNo("PC-A"); binding.setTripId(1L); binding.setTripNo("TR-ONE"); binding.setPackageCount(30); var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setWarehouseId(92L); trip.setStatus("IN_TRANSIT"); trip.setRecordVersion(10L); @@ -31,7 +31,7 @@ class FleetHandoverPdaQueryTest { {"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL","driverName":"原司机"}} """); when(history.selectList(any())).thenReturn(List.of(departed)); - when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of( + when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "92", 30, List.of( new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z"), List.of()), "DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)), new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null, List.of()), @@ -49,14 +49,18 @@ class FleetHandoverPdaQueryTest { assertEquals("REPLACEMENT", result.deliveries().get(1).transport().vehiclePlate()); String serialized = json.writeValueAsString(result); assertFalse(serialized.contains("customerId")); assertFalse(serialized.contains("warehouseId")); assertFalse(serialized.contains("vehicleId")); - verify(handovers).latestForCargo(eq(1L), argThat(value -> value.contains("CUSTOMER-A") && value.contains("PC-A"))); + verify(handovers).latestForCargo(eq(1L), argThat(value -> value.contains("PC-A") && !value.contains("customerId"))); verify(dispatches, never()).handover(any(), any()); } - @Test void staleBindingToAnotherCustomerCannotExposeDeliveryResources() { - when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-B", "92", 30, List.of())); + @Test void incompleteTruckEvidenceCannotExposeDeliveryResources() { + when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "93", 30, List.of())); assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(92, "PC-A")); verifyNoInteractions(history, handovers); } + @Test void sameWarehouseEmptyEvidenceCannotBorrowTheTruckHeaderQuantity() { + when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",30,List.of())); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.status(92,"PC-A"));verifyNoInteractions(history,handovers); + } @Test void controllerHasOnlyReadRouteAndRequiresThePdaIdentityPolicy() { var policy = PdaFleetHandoverController.class.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value(); assertTrue(policy.contains("lastleg:pda:operate")); assertTrue(policy.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java index 45c32b142..90216b54c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java @@ -71,7 +71,7 @@ class FleetHandoverTransactionTest { var inspection = mock(FleetHandoverInspectionService.class); var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10); var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null, List.of()); - var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10, + var evidence = new FleetTransitEvidence("PC-A", "92", 10, List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone")))); when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(), new FleetHandoverPolicy.Inspection(List.of(cargo), List.of(), Set.of("pallet"), new Load(1, BigDecimal.valueOf(100), BigDecimal.valueOf(2))), List.of(evidence))); @@ -98,7 +98,6 @@ class FleetHandoverTransactionTest { assertEquals("11", result.snapshot().previous().vehicleId()); assertEquals("12", result.snapshot().replacement().vehicleId()); assertEquals(result.handoverNo(), service.confirm("TR-ONE", "another-operator", command).handoverNo()); verify(dispatches, times(1)).handover(any(), any()); - verify(dispatches, never()).depart(anyString(), anyString(), anyString(), anyString(), any(), any()); assertEquals(1, repository.count(1)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java index b2b496996..d2e3f8c22 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java @@ -3,7 +3,9 @@ package com.baiyue.lastleg.fleet.exception; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.mapper.*; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; import com.baiyue.lastleg.dispatch.mapper.*; import com.baiyue.lastleg.dispatch.pda.mapper.*; @@ -17,11 +19,15 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class FleetTransitEvidenceTest { - @Test void removingOriginalPhysicalItemDelegatesToDispatchAuthority() { - var service=mock(com.baiyue.lastleg.dispatch.service.DispatchService.class); - org.springframework.test.util.ReflectionTestUtils.setField(adapter,"dispatchService",service); - adapter.removeItem("PC-1",41L); - verify(service).removeItem("PC-1",41L);verifyNoMoreInteractions(service); + @Test void twoCustomersAreReadAsOneTruckWithoutAHeaderOwner() { + var other=new DispatchItem();other.setCustomerId("CUSTOMER-B");other.setPackageCount(3); + when(aggregates.cargo(11)).thenReturn(List.of(item,other));dispatch.setShippedPackageCount(13); + var outbound=new OutboundOrder();outbound.setId(32L);outbound.setCustomerId("CUSTOMER-B");outbound.setDispatchOrderId(11L);outbound.setOverseasWarehouseId("21");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3); + var second=new DeliveryOrder();second.setId(42L);second.setDeliveryNo("DL-2");second.setCustomerId("CUSTOMER-B");second.setDispatchOrderId(11L);second.setOutboundOrderId(32L);second.setOverseasWarehouseId("21");second.setPackageCount(3);second.setStatus("DELIVERING");second.setVersion(0L);second.setDestinationType("FBA");second.setDestinationWarehouseCode("LAX9"); + when(outbounds.findAllByDispatch(11)).thenReturn(List.of(this.outbound,outbound));when(deliveries.findAllByDispatch(11)).thenReturn(List.of(delivery,second)); + var fact=adapter.terminalEvidence("PC-1",false); + assertEquals(List.of("CUSTOMER-A","CUSTOMER-B"),fact.deliveries().stream().map(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.TerminalDelivery::customerId).toList()); + assertEquals(13,fact.shippedPackages());assertFalse(java.util.Arrays.stream(fact.getClass().getRecordComponents()).anyMatch(c->c.getName().equals("customerId"))); } @BeforeAll static void initializeDeliveryMetadata() { com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( @@ -29,41 +35,30 @@ class FleetTransitEvidenceTest { DeliveryOrder.class); } - final DispatchOrderMapper orders = mock(DispatchOrderMapper.class); - final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class); - final DeliveryOrderMapper deliveries = mock(DeliveryOrderMapper.class); - final DeliveryOperationMapper operations = mock(DeliveryOperationMapper.class); - final DispatchPdaWorkflowService workflow = mock(DispatchPdaWorkflowService.class); - final DispatchFleetAdapter adapter = new DispatchFleetAdapter(orders, mock(DispatchPdaTaskMapper.class), - outbounds, deliveries, mock(DispatchItemMapper.class), mock(DispatchPdaPickMapper.class), workflow, operations); - final DispatchOrder dispatch = new DispatchOrder(); - final DeliveryOrder delivery = new DeliveryOrder(); - + final DispatchAggregateRepository aggregates=mock(DispatchAggregateRepository.class); + final OutboundOrderMapper outbounds=mock(OutboundOrderMapper.class);final DeliveryOrderMapper deliveries=mock(DeliveryOrderMapper.class); + final DeliveryOperationMapper operations=mock(DeliveryOperationMapper.class); + final com.baiyue.lastleg.dispatch.service.DispatchAggregateService workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class); + final DispatchFleetAdapter adapter=new DispatchFleetAdapter(aggregates,outbounds,deliveries,operations,mock(com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository.class),workflow,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + final DispatchAggregate dispatch=new DispatchAggregate();final DispatchItem item=new DispatchItem();final OutboundOrder outbound=new OutboundOrder();final DeliveryOrder delivery=new DeliveryOrder(); FleetTransitEvidenceTest() { - dispatch.setId(11L); dispatch.setDispatchNo("PC-1"); dispatch.setCustomerId("CUSTOMER-A"); - dispatch.setOverseasWarehouseId("21"); dispatch.setStatus("DEPARTED"); dispatch.setShippedPackageCount(10); - var outbound = new OutboundOrder(); outbound.setId(31L); outbound.setStatus("OUTBOUNDED"); outbound.setPackageCount(10); - delivery.setId(41L); delivery.setDeliveryNo("DL-1"); delivery.setCustomerId("CUSTOMER-A"); - delivery.setDispatchOrderId(11L); delivery.setOutboundOrderId(31L); delivery.setOverseasWarehouseId("21"); - delivery.setPackageCount(10); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); - delivery.setDestinationType("FBA"); delivery.setDestinationWarehouseCode("ONT8"); - when(orders.findGlobalByDispatchNo("PC-1")).thenReturn(dispatch); - when(orders.lockGlobalByDispatchNo("PC-1")).thenReturn(dispatch); - when(outbounds.findByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound); - when(outbounds.lockByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound); - when(deliveries.findByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery)); - when(deliveries.lockByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery)); + dispatch.setId(11L);dispatch.setDispatchNo("PC-1");dispatch.setOverseasWarehouseId("21");dispatch.setStatus("ACTIVE");dispatch.setStage("DELIVERING");dispatch.setDepartedAt(LocalDateTime.now(java.time.ZoneOffset.UTC).minusDays(1));dispatch.setShippedPackageCount(10); + item.setCustomerId("CUSTOMER-A");item.setPackageCount(10);when(aggregates.cargo(11)).thenReturn(List.of(item)); + outbound.setId(31L);outbound.setCustomerId("CUSTOMER-A");outbound.setDispatchOrderId(11L);outbound.setOverseasWarehouseId("21");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(10); + delivery.setId(41L);delivery.setDeliveryNo("DL-1");delivery.setCustomerId("CUSTOMER-A");delivery.setDispatchOrderId(11L);delivery.setOutboundOrderId(31L);delivery.setOverseasWarehouseId("21");delivery.setPackageCount(10);delivery.setStatus("DELIVERING");delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8"); + when(aggregates.find("PC-1")).thenReturn(dispatch);when(aggregates.lock("PC-1")).thenReturn(dispatch); + when(outbounds.findAllByDispatch(11)).thenReturn(List.of(outbound));when(outbounds.lockAllByDispatch(11)).thenReturn(List.of(outbound)); + when(deliveries.findAllByDispatch(11)).thenReturn(List.of(delivery));when(deliveries.lockAllByDispatch(11)).thenReturn(List.of(delivery)); } - @Test void unresolvedReturnKeepsCargoAndUsesCustomerScopedLocks() { delivery.setStatus("RETURN_PENDING"); var fact = adapter.transitEvidence("PC-1", true); assertNull(fact.deliveries().get(0).cargo().terminalAt()); assertEquals("FBA:ONT8", fact.deliveries().get(0).cargo().destinationKey()); - var order = inOrder(orders, outbounds, deliveries); - order.verify(orders).lockGlobalByDispatchNo("PC-1"); - order.verify(outbounds).lockByDispatch("CUSTOMER-A", 11L); - order.verify(deliveries).lockByOutbound("CUSTOMER-A", 31L); + var order = inOrder(aggregates, outbounds, deliveries); + order.verify(aggregates).lock("PC-1"); + order.verify(outbounds).lockAllByDispatch(11); + order.verify(deliveries).lockAllByDispatch(11); verifyNoInteractions(workflow); } @@ -85,7 +80,7 @@ class FleetTransitEvidenceTest { assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(), adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt()); var terminal=adapter.terminalEvidence("PC-1",false); - assertEquals("CUSTOMER-A",terminal.customerId());assertEquals(10,terminal.shippedPackages()); + assertEquals("CUSTOMER-A",terminal.deliveries().get(0).customerId());assertEquals(10,terminal.shippedPackages()); var returnedDelivery=terminal.deliveries().get(0); assertEquals("FBA:ONT8",returnedDelivery.destinationKey());assertEquals(10,returnedDelivery.packages()); assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),returnedDelivery.terminalAt()); @@ -103,9 +98,9 @@ class FleetTransitEvidenceTest { } @Test void preDepartureAndCancelledDeliveryCannotBecomeHandoverCargo() { - dispatch.setStatus("READY_TO_DEPART"); + dispatch.setStage("AFTER_LOADING"); assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); - dispatch.setStatus("DEPARTED"); delivery.setStatus("CANCELLED"); + dispatch.setStage("DELIVERING"); delivery.setStatus("CANCELLED"); assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); } @@ -115,7 +110,7 @@ class FleetTransitEvidenceTest { when(deliveries.update(isNull(), any())).thenReturn(1); adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone")); verify(deliveries).update(isNull(), any()); - verify(orders, never()).update(any(), any()); + verify(aggregates, never()).saveState(any(), anyLong()); verifyNoInteractions(workflow); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java index 04c6342e5..f48e8ff79 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java @@ -46,13 +46,13 @@ class FleetPickupServiceTest { source.setDeliveryMethod("WAREHOUSE_PICKUP");when(repository.task(1L)).thenReturn(null);when(repository.lockTask(1L)).thenReturn(null); var other=source(2L,"C2","WT-2","200");when(repository.source("C2","WT-2",true)).thenReturn(other); var mixed=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1"),new FleetPickupModels.Source("C2","WT-2")),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-2"); - assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).create(anyString(),any()); + assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).createPickup(anyString(),any()); var duplicate=new FleetPickupModels.Create(List.of(plan.sources().get(0),plan.sources().get(0)),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-3"); assertThatThrownBy(()->service.create("OP",duplicate)).hasMessageContaining("重复选择"); } @Test void alreadyScheduledSourceIsNotScheduledAgain() { var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1"); - assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).create(anyString(),any()); + assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).createPickup(anyString(),any()); } @Test void pickupQuantityDoesNotOverwriteForecastAndReplayDoesNotWriteTwice() { when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java new file mode 100644 index 000000000..7be302e22 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.Fixture; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +/** 真实主单/Fleet/预订Mapper及事务;沿GET route所用应用读取检验站序,不调用环境HTTP。 */ +class FleetDispatchRoutePersistenceTest { + @Test void lastPalletRemovalPrunesOnlyItsStopPreservesOrderAndAppendsNewDestinations() throws Exception { + var s=new Scenario();var f=s.f; + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-prune",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5),f.source(3,"103",503,"SEA1",4))))); + var origin=s.route(created.dispatchNo()).stops().get(0); + var ordered=f.tx.execute(t->f.service.reorderRoute(f.actor,created.dispatchNo(),new DispatchWorkModels.RouteCommand(created.recordVersion(),"route-order",List.of("FBA:SEA1","FBA:ONT8","FBA:LAX9"),"确认卸货顺序"))); + long item=s.item(1);var request=new DispatchCommands.ReasonCommand(ordered.recordVersion(),"remove-last-ont8","移出本目的地最后一托"); + var removed=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request)); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:SEA1","FBA:LAX9"); + var added=f.tx.execute(t->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(removed.recordVersion(),"append-new-destination",List.of(f.source(4,"104",504,"ABE2",2)),List.of()))); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:SEA1","FBA:LAX9","FBA:ABE2"); + assertThat(s.route(created.dispatchNo()).stops().get(0)).isEqualTo(origin); + var beforeReplay=s.state(created.dispatchNo());var replay=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request)); + assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(removed.recordVersion());assertThat(s.state(created.dispatchNo())).isEqualTo(beforeReplay);assertThat(s.route(created.dispatchNo()).recordVersion()).isEqualTo(added.recordVersion()); + } + + @Test void anotherPalletAtTheSameDestinationKeepsTheStopAndAnEmptyTruckKeepsOnlyItsOrigin() throws Exception { + var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-same-destination",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"ONT8",5))))); + var origin=s.route(created.dispatchNo()).stops().get(0);String trip=s.trip(); + var preview=f.fleet.capacityPlan(trip);var measurements=preview.cargo().stream().map(c->new FleetExecutionModels.Measurement(c.warehousePalletId(),1,BigDecimal.TEN,BigDecimal.ONE,1)).toList(); + f.tx.execute(t->f.fleet.confirmCapacityPlan(trip,f.actor.operatorId(),new FleetExecutionModels.Confirm(preview.recordVersion(),preview.manifestHash(),measurements,"实际计划容量"))); + assertThat(f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class)).isNotNull(); + var first=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(created.recordVersion(),"remove-first-same-destination","移出一托"))); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:ONT8");assertThat(f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class)).isNull(); + var empty=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(2),new DispatchCommands.ReasonCommand(first.recordVersion(),"remove-all-cargo","移出最后一托"))); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9");assertThat(s.route(created.dispatchNo()).stops().get(0)).isEqualTo(origin);assertThat(empty.counts().packageCount()).isZero();assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isZero(); + } + + @ParameterizedTest @ValueSource(strings={"CONFIRMED","RECEIVED","BOUND"}) + void activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String status) throws Exception { + var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-booking-"+status,List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5))))); + var ordered=f.tx.execute(t->f.service.reorderRoute(f.actor,created.dispatchNo(),new DispatchWorkModels.RouteCommand(created.recordVersion(),"booking-stop-order",List.of("FBA:ONT8","FBA:LAX9"),"原承诺站序"))); + s.booking(status,"FBA:ONT8");var original=s.route(created.dispatchNo()).stops(); + var removed=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(ordered.recordVersion(),"remove-booked-destination","保留有效外接卸货点"))); + assertThat(s.route(created.dispatchNo()).stops()).isEqualTo(original); + f.db.update("UPDATE ll_fleet_external_booking SET status='CANCELLED'"); + f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(2),new DispatchCommands.ReasonCommand(removed.recordVersion(),"remove-after-booking-cancel","外接已取消后移出剩余货物"))); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9"); + } + + @Test void missingRequiredBookingStopRejectsTheWholeRemovalAndDoesNotInventAStop() throws Exception { + var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-missing-booking",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5))))); + s.booking("CONFIRMED","FBA:UNSUPPORTED");var before=s.state(created.dispatchNo()); + assertThatThrownBy(()->f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(created.recordVersion(),"reject-missing-booking-stop","外接目的地不完整")))).hasMessageContaining("已确认外接预订"); + assertThat(s.state(created.dispatchNo())).isEqualTo(before);assertThat(s.keys(created.dispatchNo())).doesNotContain("FBA:UNSUPPORTED"); + } + + @Test void failureAfterTripRouteWriteRollsBackMainClaimsQuantitiesAndLedgerThenTheSameKeyCanSucceed() throws Exception { + var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-write-rollback",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5))))); + long item=s.item(2);var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"route-failed-original","实际事务回滚");var before=s.state(created.dispatchNo()); + f.db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD CONSTRAINT reject_quantity_three CHECK(package_count<>3)"); + assertThatThrownBy(()->f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request))).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(s.state(created.dispatchNo())).isEqualTo(before); + f.db.execute("ALTER TABLE ll_fleet_trip_dispatch DROP CONSTRAINT reject_quantity_three"); + var saved=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request));assertThat(saved.replayed()).isFalse();assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:ONT8"); + var after=s.state(created.dispatchNo());assertThat(f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request)).replayed()).isTrue();assertThat(s.state(created.dispatchNo())).isEqualTo(after); + } + + private record State(long version,int packages,String cargoHash,String review,long fleetVersion,String stops,String plan,int bindingPackages,long items,long activeClaims,long commands,long history,long fleetHistory) { } + private static final class Scenario { + final Fixture f=new Fixture();final FleetBookingMapper bookings; + Scenario() throws Exception { + f.session.getConfiguration().addMapper(FleetBookingMapper.class);bookings=f.session.getMapper(FleetBookingMapper.class); + var table=TableInfoHelper.getTableInfo(FleetBookingEntity.class);var columns=new ArrayList();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); + for(var field:table.getFieldList())columns.add(field.getColumn()+" "+sqlType(field.getPropertyType())); + f.db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")"); + var reservations=new FleetBookingReservations(bookings,f.json);ReflectionTestUtils.setField(f.nativeFleetService,"bookingReservations",reservations);ReflectionTestUtils.setField(f.fleet,"bookingReservations",reservations); + } + long item(long plan){return f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=?",Long.class,plan);} + String trip(){return f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip",String.class);} + DispatchWorkModels.Route route(String number){return f.tx.execute(t->f.service.route(f.actor,number));} + List keys(String number){return route(number).stops().stream().map(DispatchWorkModels.Stop::destinationKey).toList();} + /** 预订是本切片的持久化输入;未声称执行原外接确认流程。 */ + void booking(String status,String destination) throws Exception { + var row=new FleetBookingEntity();row.setId(901L);row.setTripId(f.db.queryForObject("SELECT id FROM ll_fleet_trip",Long.class));row.setTripNo(trip());row.setBookingNo("BOOKING-ROUTE");row.setCustomerId("201");row.setStatus(status);row.setDestinationKey(destination);row.setDestinationLabel(destination);row.setRecordVersion(0L);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt()); + row.setCargoJson(f.json.writeValueAsString(new FleetBookingModels.Cargo(destination,"测试外接货",1,1,BigDecimal.ONE,BigDecimal.ONE,null,null,null,null,null,null)));bookings.insert(row); + } + State state(String number){var root=f.repository.find(number);return new State(root.getRecordVersion(),root.getPackageCount(),root.getCargoHash(),root.getDispatchReviewJson(),f.db.queryForObject("SELECT record_version FROM ll_fleet_trip",Long.class),f.db.queryForObject("SELECT stops_json FROM ll_fleet_trip",String.class),f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class),f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class),count("ll_dispatch_item"),f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class),count("ll_dispatch_command"),count("ll_dispatch_history"),count("ll_fleet_trip_history"));} + long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);} + static String sqlType(Class type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==BigDecimal.class?"DECIMAL(18,6)":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(8000)";} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java index 0fff22d9f..527a6fa73 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java @@ -1,33 +1,23 @@ package com.baiyue.lastleg.fleet.trip; - -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; -import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -import static org.mockito.ArgumentMatchers.any; - class FleetDispatchWorkflowQueryTest { - @Test - void batchesReviewsAndKeepsCustomerDispatchDecisionsIndependent() { - var bindings = mock(FleetTripDispatchMapper.class); - var trips = mock(FleetTripMapper.class); - var first = new FleetTripDispatch(); first.setDispatchNo("PC-A"); first.setTripId(1L); first.setTripNo("TR-A"); - first.setReviewJson("{\"status\":\"REJECTED\",\"reason\":\"货物需重新核对\"}"); - var second = new FleetTripDispatch(); second.setDispatchNo("PC-B"); second.setTripId(1L); second.setTripNo("TR-A"); - var trip = new FleetTripEntity(); trip.setId(1L); - trip.setLoadingReviewJson("{\"status\":\"APPROVED\",\"reason\":\"审核通过\"}"); - when(bindings.findByDispatchNos(List.of("PC-A", "PC-B", "PC-C"))).thenReturn(List.of(first, second)); - when(trips.selectBatchIds(any())).thenReturn(List.of(trip)); - var query = new FleetDispatchWorkflowQuery(bindings, trips, new ObjectMapper().findAndRegisterModules()); - var result = query.read(List.of("PC-A", "PC-B", "PC-C")); - assertEquals("REJECTED", result.get("PC-A").dispatchReview().status()); - assertEquals("货物需重新核对", result.get("PC-A").dispatchReview().reason()); - assertEquals("PENDING", result.get("PC-B").dispatchReview().status()); - assertEquals("APPROVED", result.get("PC-B").loadingReview().status()); - assertFalse(result.containsKey("PC-C")); - verify(trips).selectBatchIds(List.of(1L)); - verifyNoMoreInteractions(trips); - assertTrue(query.read(List.of()).isEmpty()); + @Test void batchesCurrentMainReviewsWithoutCustomerOrBindingReviewCopies() { + var bindings=mock(FleetTripDispatchMapper.class);var gateway=mock(FleetDispatchGateway.class); + var first=new FleetTripDispatch();first.setDispatchNo("DS-A");first.setTripNo("TR-A");first.setTripId(1L); + var second=new FleetTripDispatch();second.setDispatchNo("DS-B");second.setTripNo("TR-B");second.setTripId(2L); + var rejected=new FleetTripModels.ReviewState("REJECTED","核对本车来源","reviewer",null,null); + var waiting=new FleetTripModels.ReviewState("NOT_SUBMITTED",null,null,null,null); + var approved=new FleetTripModels.ReviewState("APPROVED",null,"reviewer",null,null); + when(bindings.findByDispatchNos(List.of("DS-A","DS-B","DS-C"))).thenReturn(List.of(first,second)); + when(gateway.workflows(List.of("DS-A","DS-B"))).thenReturn(Map.of("DS-A",new FleetDispatchGateway.Workflow("ACTIVE","BEFORE_LOADING",5,rejected,waiting),"DS-B",new FleetDispatchGateway.Workflow("ACTIVE","LOADING",9,approved,approved))); + var query=new FleetDispatchWorkflowQuery(bindings,gateway);var result=query.read(List.of("DS-A","DS-B","DS-C")); + assertThat(result).containsOnlyKeys("DS-A","DS-B");assertThat(result.get("DS-A").dispatchReview().status()).isEqualTo("REJECTED"); + assertThat(result.get("DS-A").loadingReview().status()).isEqualTo("NOT_SUBMITTED");assertThat(result.get("DS-B").loadingReview().status()).isEqualTo("APPROVED"); + assertThat(result.get("DS-B").tripNo()).isEqualTo("TR-B");verify(gateway).workflows(List.of("DS-A","DS-B"));verifyNoMoreInteractions(gateway); + assertThat(query.read(List.of())).isEmpty(); + assertThat(Arrays.stream(FleetTripDispatch.class.getDeclaredFields()).map(java.lang.reflect.Field::getName)).doesNotContain("customerId","reviewJson"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index 6d26a90cd..61d092e6c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -19,6 +19,7 @@ class FleetTripApiContractTest { for(var spec:java.util.List.of(document,preparation,pool))paths.putAll((Map)spec.get("paths")); String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); module += Files.readString(Path.of("../iam/src/main/resources/db/fleet-capacity-offer-permissions.sql")); + module += Files.readString(Path.of("../iam/src/main/resources/db/phase5-delivery-permissions.sql")); String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); int count = 0; for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class, PlatformFleetCapacityOfferController.class)) { @@ -46,7 +47,7 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(54); + assertThat(count).isEqualTo(46); verifyRefs(document, document); verifyRefs(preparation, preparation); verifyRefs(pool, pool); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 7c434ea61..910d83b73 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -49,16 +49,9 @@ class FleetTripAuthorizationTest { var controller = context.getBean(PlatformFleetTripController.class); var service = context.getBean(FleetTripService.class); controller.detail("TR-TEST"); verify(service).detail("TR-TEST"); - assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.assign(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(() -> controller.prepare(null,null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(() -> controller.reviewLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.complete(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.release(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.confirmCapacityPlan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); @@ -85,6 +78,23 @@ class FleetTripAuthorizationTest { assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class); } finally {SecurityContextHolder.clearContext();} } + @Test void externalArrangeRequiresCurrentCreateAndAllIndependentPermissions() { + var required=List.of("platform:fleet:capacity-offer:manage","platform:fleet:trip:booking-edit","platform:fleet:trip:edit","platform:overseas-warehouse:dispatch:create"); + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(PlatformFleetCapacityOfferController.class); + var service=context.getBean(FleetCapacityOfferService.class); + for(var missing:required) { + var permissions=new java.util.ArrayList<>(required);permissions.remove(missing);permissions.add("platform:overseas-warehouse:inbound-order:smart-dispatch"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",permissions.stream().map(SimpleGrantedAuthority::new).toList())); + assertThatThrownBy(()->controller.arrange("FB-1",null)).isInstanceOf(AccessDeniedException.class); + } + verifyNoInteractions(service); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",required.stream().map(SimpleGrantedAuthority::new).toList())); + controller.arrange("FB-1",null);verify(service).arrangeDispatch("FB-1",null); + assertThat(java.util.Arrays.stream(PlatformFleetTripController.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName)) + .doesNotContain("create","prepare","dispatchCandidates","bind","unbind","reviewDispatch","reviewLoading","confirmLoading"); + }finally{SecurityContextHolder.clearContext();} + } @Configuration @EnableMethodSecurity static class Config { /** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */ @@ -94,7 +104,7 @@ class FleetTripAuthorizationTest { @Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); } @Bean FleetCapacityOfferCommandMapper commands() { return mock(FleetCapacityOfferCommandMapper.class); } @Bean FleetDispatchPlanningGateway planning() { return mock(FleetDispatchPlanningGateway.class); } - @Bean FleetTripPreparationService preparation() { return mock(FleetTripPreparationService.class); } + @Bean FleetDispatchGateway dispatches() { return mock(FleetDispatchGateway.class); } @Bean com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage() { return mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class); } @Bean FleetCapacityOfferService capacityOffers() { return mock(FleetCapacityOfferService.class); } @Bean PlatformFleetCapacityOfferController poolController(FleetCapacityOfferService service) { return new PlatformFleetCapacityOfferController(service); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index a08940bff..0d0198ac6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -1,1265 +1,89 @@ package com.baiyue.lastleg.fleet.trip; -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.lastleg.fleet.driver.*; -import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; -import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; -import org.springframework.test.util.ReflectionTestUtils; -import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.Fixture; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; -import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.AfterEach; -import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.aop.framework.ProxyFactory; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; -import org.springframework.transaction.interceptor.TransactionInterceptor; -import org.springframework.web.server.ResponseStatusException; - import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.*; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.*; -import java.util.regex.Pattern; - +import java.util.*; import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +/** 原客户子排车/手工绑定夹具退出;真实主单、关联、容量及资源结束使用同一H2事务。 */ class FleetTripPersistenceTest { - @Test void originalBatchReferencesSurviveRemovalReentryAndLaterDispatchReassignment() throws Exception { - String number=assignedTrip();dispatch(11,"CLIENT-A","92");dispatch(12,"CLIENT-B","92"); - var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); - ReflectionTestUtils.setField(service,"appointmentAllocations",allocations); - var a=List.of(batchReference("1","CLIENT-A",6),batchReference("2","CLIENT-A",4)); - var originalB=List.of(batchReference("3","CLIENT-B",10)); - var current=new java.util.HashMap>();current.put(11L,a);current.put(12L,originalB); - when(allocations.references(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return ids.stream().collect(java.util.stream.Collectors.toMap(id->id,id->current.getOrDefault(id,List.of())));}); - service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(1L,"DP-11","加入甲")); - var both=service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(2L,"DP-12","加入乙")); - assertThat(both.dispatches().get(0).appointmentBatches()).containsExactlyElementsOf(a);assertThat(both.previousDispatches()).isEmpty(); - service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(3L,"DP-12","乙后送")); - current.put(12L,List.of(batchReference("4","CLIENT-B",4),batchReference("5","CLIENT-B",6))); - var reopened=service.detail(number); - assertThat(reopened.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A"); - assertThat(reopened.previousDispatches()).hasSize(1); - var previous=reopened.previousDispatches().get(0);assertThat(previous.dispatchNo()).isEqualTo("DP-12");assertThat(previous.customerId()).isEqualTo("CLIENT-B"); - assertThat(previous.recordVersion()).isEqualTo(3);assertThat(previous.recordedAt()).isNotNull();assertThat(previous.appointmentBatches()).isEqualTo(originalB); - var rebound=service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(4L,"DP-12","按当前后送批重入")); - assertThat(rebound.dispatches().get(1).appointmentBatches()).isEqualTo(current.get(12L)); - assertThat(rebound.previousDispatches()).hasSize(1);assertThat(rebound.previousDispatches().get(0).appointmentBatches()).isEqualTo(originalB); - var audit=service.history(number,1,20).records().stream().filter(h->h.recordVersion()==3).findFirst().orElseThrow(); - assertThat(audit.snapshot().dispatches().get(1).appointmentBatches()).isEqualTo(originalB); - assertThat(rebound.appointmentHistoryUnavailableReason()).isNull(); - var json=new ObjectMapper().findAndRegisterModules(); - var incomplete=json.valueToTree(audit.snapshot());((com.fasterxml.jackson.databind.node.ObjectNode)incomplete.path("dispatches").get(1)).remove("appointmentBatches"); - jdbc.update("UPDATE ll_fleet_trip_history SET snapshot_json=? WHERE trip_id=(SELECT id FROM ll_fleet_trip WHERE trip_no=?) AND record_version=3",json.writeValueAsString(incomplete),number); - var readable=service.detail(number);assertThat(readable.dispatches()).hasSize(2);assertThat(readable.dispatches().get(1).appointmentBatches()).isEqualTo(current.get(12L)); - assertThat(readable.previousDispatches()).isEmpty();assertThat(readable.appointmentHistoryUnavailableReason()).contains("追踪不完整"); + @Test void oneWholeTruckProjectsBothReviewsFromTheRealMain() throws Exception { + var f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-one-main",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5))))); + String trip=trip(f);var before=f.nativeFleetService.detail(trip); + assertThat(before.dispatches()).hasSize(1);assertThat(before.dispatches().get(0).dispatchNo()).isEqualTo(created.dispatchNo()); + assertThat(before.dispatches().get(0).packageCount()).isEqualTo(8);assertThat(before.dispatches().get(0).review().status()).isEqualTo("NOT_SUBMITTED"); + f.approve(created);var approved=f.nativeFleetService.detail(trip); + assertThat(approved.dispatches().get(0).review().status()).isEqualTo("APPROVED");assertThat(approved.loadingReview().status()).isEqualTo("APPROVED"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Long.class)).isOne(); } - private static com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batchReference(String id,String customer,int packages) { - return new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:"+id,"AP-"+id,customer, - List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:"+customer,"FBA:ONT8",packages))); + @Test void externalVehicleCapacityPlanUsesActualWholeTruckStopsAndSnapshots() throws Exception { + var f=new Fixture();f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-capacity",List.of(f.source(1,"101",501,"ONT8",3))))); + var preview=f.fleet.capacityPlan(trip(f));assertThat(preview.blockReason()).isNull();assertThat(preview.cargo()).hasSize(1); + var plan=f.tx.execute(s->f.fleet.confirmCapacityPlan(trip(f),f.actor.operatorId(),new FleetExecutionModels.Confirm(preview.recordVersion(),preview.manifestHash(),List.of(measurement(1,100)),"核对真实容量"))); + assertThat(plan.planValid()).isTrue();assertThat(plan.capacityPlan().pallets()).hasSize(1); + assertThat(f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class)).contains("501"); + assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("BEFORE_LOADING"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero(); } - private JdbcTemplate jdbc; - private FleetTripService service; - private FleetTripPreparationService preparation; - private FleetDispatchPlanningGateway planning; - private DriverService drivers; - private VehicleRepository vehicles; - private VehicleApplicationService vehicleService; - private DataSourceTransactionManager transactions; - private FleetDispatchBindingGuard bindingGuard; - private FleetTripExecutionService execution; - private FleetTripCompletionService completion; - private FleetBookingService bookingService; - private FleetCapacityOfferService capacityOffers; - private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders; - private FleetBookingIncomingGateway incoming; - private FleetBookingQuoteContext quoteContext; - private com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService quoteCandidates; - private Instant departure, end; - @AfterEach void clearAuthentication() { SecurityContextHolder.clearContext(); } - - private FleetQuoteCandidateModels.SourceSnapshot enableQuote() { - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of(new SimpleGrantedAuthority("platform:fleet:quote:list")))); - var context=new FleetQuoteCandidateModels.Context(9L,"Los Angeles","ONT8","USD",LocalDate.of(2026,10,3),"PALLET"); - when(quoteContext.resolve(any(),eq(9L),eq("FBA:ONT8"),eq("USD"),eq("PALLET"))).thenReturn(context); - var source=new FleetQuoteCandidateModels.SourceSnapshot("FQ-TEST",2L,"LOOSE_DELIVERY","9","外接客户","Los Angeles","ONT8",new BigDecimal("30"),"USD","PALLET",BigDecimal.ZERO,LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),context.effectiveOn()); - when(quoteCandidates.lockAndResolve(eq(context),any())).thenReturn(source); - return source; + @Test void capacityFailureOrStaleFingerprintDoesNotWritePlanOrHistory() throws Exception { + var f=new Fixture();f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-capacity-reject",List.of(f.source(1,"101",501,"ONT8",3))))); + var preview=f.fleet.capacityPlan(trip(f));long history=f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history",Long.class); + assertThatThrownBy(()->f.tx.execute(s->f.fleet.confirmCapacityPlan(trip(f),"operator",new FleetExecutionModels.Confirm(preview.recordVersion(),"stale",List.of(measurement(1,100)),"复核")))).isInstanceOf(RuntimeException.class); + assertThatThrownBy(()->f.tx.execute(s->f.fleet.confirmCapacityPlan(trip(f),"operator",new FleetExecutionModels.Confirm(preview.recordVersion(),preview.manifestHash(),List.of(measurement(1,10001)),"超重")))).isInstanceOf(RuntimeException.class); + assertThat(f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class)).isNull(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history",Long.class)).isEqualTo(history); } - private static FleetQuoteCandidateModels.QuoteRef quoteReference(){return new FleetQuoteCandidateModels.QuoteRef("FQ-TEST",2L,"LOOSE_DELIVERY");} - - @BeforeEach void initialize() throws Exception { - departure = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.MILLIS); - end = departure.plus(12, ChronoUnit.HOURS); - var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); - jdbc = new JdbcTemplate(dataSource); transactions = new DataSourceTransactionManager(dataSource); - String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", - "ll_fleet_capacity_offer", "ll_fleet_capacity_offer_command", "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order", - "ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation", - "ll_outbound_pallet_item", "ll_return_carton_stock_receipt", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) { - var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); - assertThat(match.find()).as(table).isTrue(); - jdbc.execute(match.group().replace(" JSON", " LONGTEXT").replaceAll("(?i) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", "") - .replaceAll("\\((receipt_id|return_receipt_id|return_carton_id) IS NOT NULL\\)", "(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)")); + @Test void boundCorePlanAndResourcesMustUseTheCommonMainVersionAndReview() throws Exception { + var f=new Fixture();f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-no-second-entry",List.of())));var detail=f.nativeFleetService.detail(trip(f)); + assertThatThrownBy(()->f.tx.execute(s->f.nativeFleetService.assign(trip(f),"operator",new FleetTripModels.Assign(detail.trip().recordVersion(),1L,2L,"改派")))).hasMessageContaining("排车主单"); + assertThatThrownBy(()->f.tx.execute(s->f.nativeFleetService.updatePlan(trip(f),"operator",new FleetTripModels.Plan(detail.trip().recordVersion(),Instant.now().plusSeconds(60),Instant.now().plusSeconds(120),List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("终点",null,"FBA:ONT8")),null,"改计划")))).hasMessageContaining("排车主单"); + assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero(); + } + @Test void externalCargoCompletionAndTwoTaskEndsAreIndependentAndReplayable() throws Exception { + var scenario=new CompletedCargo();var f=scenario.f;var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isTrue(); + var request=new FleetCompletionModels.Complete(inspection.recordVersion(),"complete-original","全部货物实际履约"); + var complete=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(complete.trip().status()).isEqualTo("COMPLETED"); + FleetTripModels.Detail recovered=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(recovered).isEqualTo(complete); + assertThat(complete.trip().vehicleRelease()).isNull();assertThat(complete.trip().driverRelease()).isNull(); + var end=Instant.now().minusSeconds(10);var vehicle=new FleetCompletionModels.Release(complete.trip().recordVersion(),"VEHICLE",end,"实际交车点","RETURNED","vehicle-end-original","外配车实际结束"); + var released=f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle));assertThat(released.trip().vehicleRelease()).isNotNull();assertThat(released.trip().driverRelease()).isNull(); + var driver=new FleetCompletionModels.Release(released.trip().recordVersion(),"DRIVER",end,"实际任务终点",null,"driver-end-original","司机实际结束"); + var finished=f.tx.execute(s->scenario.completion.release(trip(f),"operator",driver));assertThat(finished.trip().driverRelease()).isNotNull(); + assertThat(f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle)).trip().recordVersion()).isEqualTo(finished.trip().recordVersion()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();verifyNoInteractions(scenario.vehicleService); + } + @Test void partialSignatureCannotCloseCargoOrEndExternalTasks() throws Exception { + var scenario=new CompletedCargo();var f=scenario.f;f.db.update("UPDATE ll_delivery_order SET signed_at=NULL,signed_packages=NULL,status='DELIVERING'"); + var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isFalse(); + assertThatThrownBy(()->f.tx.execute(s->scenario.completion.complete(trip(f),"operator",new FleetCompletionModels.Complete(inspection.recordVersion(),"partial-cannot-close","未全签")))).isInstanceOf(RuntimeException.class); + assertThatThrownBy(()->f.tx.execute(s->scenario.completion.release(trip(f),"operator",new FleetCompletionModels.Release(inspection.recordVersion(),"DRIVER",Instant.now().minusSeconds(1),"未结束",null,"cannot-end-driver","尚未履约")))).isInstanceOf(RuntimeException.class); + assertThat(f.db.queryForObject("SELECT completion_json FROM ll_fleet_trip",String.class)).isNull(); + } + @Test void laterFullSignatureReturnDoesNotReopenOriginalTransport() throws Exception { + var scenario=new CompletedCargo();scenario.f.db.update("UPDATE ll_delivery_order SET status='RETURN_PENDING'"); + var facts=scenario.completion.inspection(trip(scenario.f));assertThat(facts.canComplete()).isTrue();assertThat(facts.outcomes().get(0).signedPackages()).isEqualTo(3); + } + private static FleetExecutionModels.Measurement measurement(int index,int weight){return new FleetExecutionModels.Measurement("501",1,BigDecimal.valueOf(weight),BigDecimal.ONE,index);} + private static String trip(Fixture f){return f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip",String.class);} + /** 已发运起始事实作为本切片fixture;客户单据实际Mapper读取,未模拟仓储查询成功。 */ + private static final class CompletedCargo { + final Fixture f;final VehicleApplicationService vehicleService=mock(VehicleApplicationService.class);final FleetTripCompletionService completion; + CompletedCargo() throws Exception { + f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-completion",List.of(f.source(1,"101",501,"ONT8",3)))));f.approve(created);f.picked(1,"101",501,3); + f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10)); + f.db.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT',departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10)); + var root=f.repository.find(created.dispatchNo());var outbound=new OutboundOrder();outbound.setId(600L);outbound.setCustomerId("101");outbound.setOutboundNo("OB-REAL");outbound.setDispatchOrderId(root.getId());outbound.setOverseasWarehouseId("9");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);f.session.getMapper(OutboundOrderMapper.class).insert(outbound); + var delivery=new DeliveryOrder();delivery.setId(700L);delivery.setCustomerId("101");delivery.setDeliveryNo("DL-REAL");delivery.setDispatchOrderId(root.getId());delivery.setOutboundOrderId(600L);delivery.setOverseasWarehouseId("9");delivery.setStatus("DELIVERED");delivery.setPackageCount(3);delivery.setSignedPackages(3);delivery.setSignedAt(LocalDateTime.now().minusMinutes(5));delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");f.session.getMapper(DeliveryOrderMapper.class).insert(delivery); + completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,mock(VehicleRepository.class),vehicleService,f.json); } - jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64))"); - jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)"); - jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))"); - jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))"); - var configuration = new MybatisConfiguration(); - configuration.addMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class); - for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class, - FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, FleetCapacityOfferMapper.class, FleetCapacityOfferCommandMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class, - DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper); - var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor(); - pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2)); - configuration.addInterceptor(pagination); - var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration); - var session = new SqlSessionTemplate(factory.getObject()); - var directory = mock(FleetWarehouseDirectory.class); - when(directory.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS")); - var files = mock(DriverFileGateway.class); - when(files.upload(anyLong(), anyString(), any())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(0, Long.class) + 1000), "license.pdf", "application/pdf", 8)); - when(files.metadata(anyLong(), anyLong())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(1, Long.class)), "license.pdf", "application/pdf", 8)); - var json = new ObjectMapper().registerModule(new JavaTimeModule()); - drivers = transactional(new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), files, json, directory), DriverService.class); - vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class)); - var resources = session.getMapper(FleetResourceMapper.class); - vehicleService = transactional(new VehicleApplicationService(vehicles, new FleetVehicleAssignmentGuard(resources)), VehicleApplicationService.class); - var bookingMapper = session.getMapper(FleetBookingMapper.class); - var reservations = new FleetBookingReservations(bookingMapper, json); - poolOrders=mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class); - when(poolOrders.businessOrderNumbers(anyCollection())).thenReturn(java.util.Map.of()); - ReflectionTestUtils.setField(reservations,"offers",session.getMapper(FleetCapacityOfferMapper.class)); - ReflectionTestUtils.setField(reservations,"poolOrders",poolOrders); - var bindingMapper = session.getMapper(FleetTripDispatchMapper.class); - bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class); - var workflow = new DispatchPdaWorkflowService(); - var deliveryExecution = new com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository( - session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class))); - ReflectionTestUtils.setField(workflow, "deliveryExecution", deliveryExecution); - ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class)); - ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class)); - ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class)); - ReflectionTestUtils.setField(workflow, "tasks", session.getMapper(DispatchPdaTaskMapper.class)); - ReflectionTestUtils.setField(workflow, "picks", session.getMapper(DispatchPdaPickMapper.class)); - ReflectionTestUtils.setField(workflow, "operations", session.getMapper(DispatchPdaOperationMapper.class)); - ReflectionTestUtils.setField(workflow, "palletLocks", session.getMapper(DispatchPalletLockMapper.class)); - ReflectionTestUtils.setField(workflow, "fleetBindings", bindingGuard); - ReflectionTestUtils.setField(workflow, "objectMapper", json); - ReflectionTestUtils.setField(workflow, "pickAttribution", mock(DispatchPickOrderAttributionService.class)); - ReflectionTestUtils.setField(workflow,"fleetPool",poolOrders); - var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class), - session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class), - transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); - ReflectionTestUtils.setField(dispatchGateway, "deliveryExecution", deliveryExecution); - ReflectionTestUtils.setField(dispatchGateway, "cargoSources", new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( - new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(session.getMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class)))); - service = transactional(new FleetTripService(new FleetTripRepository(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), bindingMapper), vehicles, drivers, directory, json, - dispatchGateway, reservations), FleetTripService.class); - preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class); - planning=mock(FleetDispatchPlanningGateway.class); - ReflectionTestUtils.setField(service,"planning",planning); - ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class)); - ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); - execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class); - ReflectionTestUtils.setField(execution,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); - var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); - when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer( - Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null)); - incoming=mock(FleetBookingIncomingGateway.class); - quoteContext=mock(FleetBookingQuoteContext.class); - quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class); - bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class); - ReflectionTestUtils.setField(bookingService,"poolOrders",poolOrders); - ReflectionTestUtils.setField(bookingService,"planning",planning); - ReflectionTestUtils.setField(bookingService,"commands",session.getMapper(FleetCapacityOfferCommandMapper.class)); - capacityOffers=transactional(new FleetCapacityOfferService(session.getMapper(FleetCapacityOfferMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class), - session.getMapper(FleetTripMapper.class),bookingMapper,bookingService,reservations,execution,customerDirectory,poolOrders,json),FleetCapacityOfferService.class); - ReflectionTestUtils.setField(capacityOffers,"appointmentCoverage",mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class)); - completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class); - } - - @Test void preparationRollsBackNewTripAndResourcesIfAnyBindingFailsThenReplaysSuccess() { - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of("platform:fleet:trip:create","platform:fleet:trip:assign","platform:fleet:trip:edit").stream().map(SimpleGrantedAuthority::new).toList())); - long vehicle=vehicle(true), driver=driver(true);dispatch(21,"A","92");dispatch(22,"B","93"); - var request=new FleetTripPreparationService.Prepare(null,null,createRequest("prepare-atomic"),vehicle,driver,List.of("DP-21","DP-22"),"prepare-atomic"); - assertThatThrownBy(()->preparation.prepare("1",request)).hasMessageContaining("同一起运仓"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isZero(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class)).isZero(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero(); - jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=22"); - jdbc.update("UPDATE ll_dispatch_pda_task SET warehouse_id='92' WHERE dispatch_order_id=22"); - var saved=preparation.prepare("1",request); - assertThat(saved.dispatches()).hasSize(2); - assertThat(preparation.prepare("1",request)).isEqualTo(saved); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isEqualTo(1); - assertThatThrownBy(()->preparation.prepare("1",new FleetTripPreparationService.Prepare(null,null,request.plan(),vehicle,driver,List.of("DP-21"),request.idempotencyKey()))).hasMessageContaining("不能更换内容"); - } - - @Test void sameWindowSaveAndConfirmIsAtomicAndReplaysOriginalResult() { - var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"prepare-register")); - var input=poolDraft(draft.offer(),2,"same-window-confirm"); - var update=new FleetCapacityOfferModels.InquiryWrite(input.tripRecordVersion(),input.offerRecordVersion(),draft.inquiry().recordVersion(),input.customerId(),input.businessType(),input.deliveryMode(),input.estimatedArrivalAt(),input.expectedPalletCount(),input.cargo(),input.quoteRef(),input.idempotencyKey()); - doThrow(new IllegalStateException("bridge failed")).when(poolOrders).confirm(any(),anyString()); - assertThatThrownBy(()->capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update)).hasMessageContaining("bridge failed"); - assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).recordVersion()).isEqualTo(draft.inquiry().recordVersion()); - assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).status()).isEqualTo("DRAFT"); - reset(poolOrders); - var confirmed=capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update); - assertThat(confirmed.inquiry().status()).isEqualTo("CONFIRMED"); - assertThat(confirmed.inquiry().progress().reservation()).isEqualTo("已保留货位"); - assertThat(capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update).replayed()).isTrue(); - verify(poolOrders,times(1)).confirm(any(),anyString()); - } - - @Test void poolSalesAreIsolatedByUserIdAndCannotConfirmOrReadAllInquiries() { - var offer=publishedOffer();poolLogin(101,false); - var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"owner-first")); - poolLogin(102,false); - assertThatThrownBy(()->capacityOffers.inquiry(first.inquiry().bookingNo())).hasMessageContaining("无权"); - assertThatThrownBy(()->capacityOffers.inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,null,null,"ALL"))).hasMessageContaining("无权"); - assertThatThrownBy(()->capacityOffers.confirm(first.inquiry().bookingNo(),poolCommand(first.inquiry(),"sales-confirm"))).hasMessageContaining("无权"); - poolLogin(101,false); - assertThat(capacityOffers.inquiries(new FleetCapacityOfferModels.InquiryQuery(1,20,null,null,null,"MINE")).records()).extracting(FleetCapacityOfferModels.Inquiry::bookingNo).containsExactly(first.inquiry().bookingNo()); - assertThat(jdbc.queryForObject("SELECT sales_owner_user_id FROM ll_fleet_external_booking WHERE booking_no=?",String.class,first.inquiry().bookingNo())).isEqualTo("101"); - } - - @Test void poolConfirmSerializesLastSpaceAndOriginalSuccessfulCommandReplaysAfterClose() throws Exception { - var offer=publishedOffer();poolLogin(101,false); - var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"pool-first")); - poolLogin(102,false); - var second=capacityOffers.createInquiry(offer.offerNo(),poolDraft(first.offer(),8,"pool-second")); - poolLogin(1,true); - var firstCurrent=capacityOffers.inquiry(first.inquiry().bookingNo()); - var secondCurrent=capacityOffers.inquiry(second.inquiry().bookingNo()); - var firstRequest=poolCommand(firstCurrent,"pool-confirm-first");var secondRequest=poolCommand(secondCurrent,"pool-confirm-second"); - var latch=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2); - java.util.concurrent.Callable a=()->poolConfirmAttempt(latch,firstCurrent.bookingNo(),firstRequest); - java.util.concurrent.Callable b=()->poolConfirmAttempt(latch,secondCurrent.bookingNo(),secondRequest); - try {var fa=workers.submit(a);var fb=workers.submit(b);latch.countDown();assertThat(List.of(fa.get(15,TimeUnit.SECONDS),fb.get(15,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);} - finally {workers.shutdownNow();} - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_external_booking WHERE status='CONFIRMED'",Integer.class)).isEqualTo(1); - var current=capacityOffers.detail(offer.offerNo()).offer();assertThat(current.destinations().get(0).remaining().palletSpaces()).isEqualTo(4); - String winner=jdbc.queryForObject("SELECT booking_no FROM ll_fleet_external_booking WHERE status='CONFIRMED'",String.class); - String loser=winner.equals(firstCurrent.bookingNo())?secondCurrent.bookingNo():firstCurrent.bookingNo(); - var loserRow=capacityOffers.inquiry(loser); - assertThatThrownBy(()->capacityOffers.confirm(loser,poolCommand(loserRow,"retry-rejected-space"))).hasMessageContaining("超过车辆"); - capacityOffers.command(offer.offerNo(),"CLOSE",new FleetCapacityOfferModels.Command(current.tripRecordVersion(),current.recordVersion(),"pool-close-test",null)); - var original=winner.equals(firstCurrent.bookingNo())?firstRequest:secondRequest; - assertThat(capacityOffers.confirm(winner,original).replayed()).isTrue(); - assertThatThrownBy(()->capacityOffers.confirm(winner,new FleetCapacityOfferModels.InquiryCommand(original.tripRecordVersion(),original.offerRecordVersion(),original.recordVersion(),original.idempotencyKey(),"changed"))).hasMessageContaining("不能更换"); - verify(poolOrders,times(1)).confirm(any(),eq("1")); - poolLogin(winner.equals(firstCurrent.bookingNo())?101:102,false); - assertThat(capacityOffers.inquiry(winner).status()).isEqualTo("CONFIRMED"); - } - - @Test void poolCommercialFailureRollsBackReservationAndOldBookingEndpointCannotBypassPool() { - var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"bridge-rollback-create")); - doThrow(new IllegalStateException("bridge unavailable")).when(poolOrders).confirm(any(),anyString()); - assertThatThrownBy(()->capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"bridge-rollback-command"))).hasMessageContaining("bridge unavailable"); - assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).status()).isEqualTo("DRAFT"); - assertThat(capacityOffers.detail(offer.offerNo()).offer().destinations().get(0).remaining().palletSpaces()).isEqualTo(12); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_capacity_offer_command WHERE action='CONFIRM'",Integer.class)).isZero(); - assertThatThrownBy(()->bookingService.confirm(offer.tripNo(),draft.inquiry().bookingNo(),"1",new FleetBookingModels.Command(draft.inquiry().tripRecordVersion(),0L,"old-pool-bypass",null))).hasMessageContaining("需求池"); - } - - @Test void liveVehicleChangesBlockCommitButDoNotFabricateDraftQuantityOrCapacity() { - var offer=publishedOffer();var currentTrip=service.detail(offer.tripNo()).trip(); - jdbc.update("UPDATE ll_vehicle SET max_pallet_spaces=20 WHERE id=?",Long.parseLong(currentTrip.vehicleId())); - var current=capacityOffers.detail(offer.offerNo()).offer(); - assertThat(current.capacityKnown()).isFalse();assertThat(current.canConfirm()).isFalse();assertThat(current.canCreateInquiry()).isTrue(); - assertThat(current.destinations()).allMatch(row->row.remaining()==null); - var unknown=new FleetBookingModels.Cargo("FBA:ONT8","待核货物",null,null,null,null,"PALLET",null,"USD",null,null,null); - var draft=capacityOffers.createInquiry(offer.offerNo(),new FleetCapacityOfferModels.InquiryWrite(current.tripRecordVersion(),current.recordVersion(),null,9L,"US_TRUCK","SELF_DELIVERY",null,null,unknown,null,"unknown-pool-cargo")); - assertThat(draft.inquiry().cargo().packageCount()).isNull();assertThat(draft.inquiry().expectedPalletCount()).isNull(); - assertThatThrownBy(()->capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"changed-vehicle-confirm"))).hasMessageContaining("车辆容量"); - } - - @Test void poolCancellationReleasesOnlyUnstartedBookingAndPreservesActualIncoming(){ - var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,8,"cancel-pool-draft")); - var confirmed=capacityOffers.confirm(draft.inquiry().bookingNo(),poolCommand(draft.inquiry(),"cancel-pool-confirm")); - var cancelled=capacityOffers.cancel(confirmed.inquiry().bookingNo(),poolCommand(confirmed.inquiry(),"cancel-pool-safe")); - assertThat(cancelled.inquiry().status()).isEqualTo("CANCELLED"); - assertThat(cancelled.offer().destinations().get(0).remaining().palletSpaces()).isEqualTo(12); - verify(poolOrders).cancelBeforeIncoming(anyLong(),eq("9"),eq("1"),isNull()); - var next=capacityOffers.createInquiry(offer.offerNo(),poolDraft(cancelled.offer(),8,"cancel-incoming-draft")); - var reserved=capacityOffers.confirm(next.inquiry().bookingNo(),poolCommand(next.inquiry(),"cancel-incoming-confirm")); - jdbc.update("UPDATE ll_fleet_external_booking SET incoming_no='WT-STARTED' WHERE booking_no=?",reserved.inquiry().bookingNo()); - assertThatThrownBy(()->capacityOffers.cancel(reserved.inquiry().bookingNo(),poolCommand(reserved.inquiry(),"cancel-incoming-refused"))).hasMessageContaining("已登记来货"); - assertThat(jdbc.queryForObject("SELECT incoming_no FROM ll_fleet_external_booking WHERE booking_no=?",String.class,reserved.inquiry().bookingNo())).isEqualTo("WT-STARTED"); - verify(poolOrders,times(1)).cancelBeforeIncoming(anyLong(),anyString(),anyString(),any()); - } - - private boolean poolConfirmAttempt(CountDownLatch latch,String booking,FleetCapacityOfferModels.InquiryCommand request) throws Exception { - poolLogin(1,true);try {latch.await(5,TimeUnit.SECONDS);capacityOffers.confirm(booking,request);return true;}catch(ResponseStatusException conflict){return false;}finally{SecurityContextHolder.clearContext();} - } - private FleetCapacityOfferModels.Offer publishedOffer(){ - poolLogin(1,true);String trip=readyTrip();var plan=execution.capacityPlan(trip); - var measured=new FleetExecutionModels.Measurement("901",18,new BigDecimal("100"),new BigDecimal("1"),1); - var confirmed=execution.confirmCapacityPlan(trip,"1",new FleetExecutionModels.Confirm(plan.recordVersion(),plan.manifestHash(),List.of(measured),"自有18托位")); - var created=capacityOffers.create(new FleetCapacityOfferModels.Write(trip,confirmed.recordVersion(),null,departure.minusSeconds(60),"调度","10086",null,"offer-create-test")); - return capacityOffers.command(created.offer().offerNo(),"PUBLISH",new FleetCapacityOfferModels.Command(confirmed.recordVersion(),0L,"offer-publish-test",null)).offer(); - } - private FleetCapacityOfferModels.InquiryWrite poolDraft(FleetCapacityOfferModels.Offer offer,int spaces,String key){ - var cargo=new FleetBookingModels.Cargo("FBA:ONT8","外接货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD","验收联系人","10086",null); - return new FleetCapacityOfferModels.InquiryWrite(offer.tripRecordVersion(),offer.recordVersion(),null,9L,"LOCAL_LOOSEBOARD","SELF_DELIVERY",departure.minusSeconds(120),2,cargo,null,key); - } - private static FleetCapacityOfferModels.InquiryCommand poolCommand(FleetCapacityOfferModels.Inquiry row,String key){return new FleetCapacityOfferModels.InquiryCommand(row.tripRecordVersion(),row.offerRecordVersion(),row.recordVersion(),key,null);} - private static void poolLogin(long id,boolean manager){ - var permissions=manager?java.util.Set.of(FleetCapacityOfferService.MANAGE):java.util.Set.of(FleetCapacityOfferService.LIST,"platform:fleet:capacity-offer:inquiry-create","platform:fleet:capacity-offer:inquiry-edit","platform:fleet:capacity-offer:inquiry-cancel"); - var user=new com.baiyue.common.security.AuthenticatedUser(id,0L,0L,id,"same-display-name","ADMIN","WEB",permissions); - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"unused",permissions.stream().map(SimpleGrantedAuthority::new).toList())); - } - - @Test void creationReplayUsesSameBusinessNumberAndRejectsChangedPayload() { - var request = createRequest("creation-one"); - var first = service.create("1", request); - assertThat(service.create("1", request).trip().tripNo()).isEqualTo(first.trip().tripNo()); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1); - assertThatThrownBy(() -> service.create("1", new FleetTripModels.Create(92L, departure, end.plusSeconds(1), request.stops(), null, request.idempotencyKey()))) - .hasMessageContaining("不同车次内容"); - assertThat(service.create("2", request).trip().tripNo()).isNotEqualTo(first.trip().tripNo()); - } - - @Test void assignmentLocksBothResourcesAndCancellationReleasesThemWithoutRemovingHistory() { - long vehicle = vehicle(true), driver = driver(true); - String first = trip(), second = trip(); - var assigned = service.assign(first, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排运输")); - assertThat(assigned.driverQualification().canDispatch()).isTrue(); - assertThat(assigned.trip().maxPalletSpaces()).isEqualTo(30); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); - assertThatThrownBy(() -> service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重复分配"))).hasMessageContaining("占用"); - assertThat(service.cancel(first, "1", new FleetTripModels.Cancel(1L, "取消安排")).trip().status()).isEqualTo("CANCELLED"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); - assertThat(service.history(first, 1, 20).records()).extracting(FleetTripModels.History::action).containsExactly("CANCEL", "ASSIGN", "CREATE"); - assertThat(service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重新安排")).trip().vehicleId()).isEqualTo(Long.toString(vehicle)); - } - - /** 下拉占用来自真实资源表,本车可保留、别车不可选,查询不预留资源。 */ - @Test void candidateOccupancySeparatesResourceKindAndReleasesAfterCancellation() { - long occupiedVehicle=vehicle(true), freeVehicle=vehicle(true), occupiedDriver=driver(true); - String first=trip(), second=trip(); - service.assign(first,"1",new FleetTripModels.Assign(0L,occupiedVehicle,occupiedDriver,"候选占用验收")); - var query=new FleetTripModels.ResourceQuery("VEHICLE",List.of(occupiedVehicle,freeVehicle,occupiedVehicle)); - var other=service.resourceOccupancy(second,query); - assertThat(other).hasSize(2); - assertThat(other.get(0)).isEqualTo(new FleetTripModels.ResourceOccupancy(Long.toString(occupiedVehicle),first,false)); - assertThat(other.get(1)).isEqualTo(new FleetTripModels.ResourceOccupancy(Long.toString(freeVehicle),null,true)); - assertThat(service.resourceOccupancy(first,query).get(0).available()).isTrue(); - assertThat(service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("DRIVER",List.of(occupiedDriver))).get(0).available()).isFalse(); - assertThat(service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("VEHICLE",List.of(occupiedDriver))).get(0).available()).isTrue(); - assertThatThrownBy(()->service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("VEHICLE",List.of()))).hasMessageContaining("有效的车辆或司机候选"); - service.cancel(first,"1",new FleetTripModels.Cancel(1L,"取消释放候选资源")); - assertThat(service.resourceOccupancy(second,query)).allMatch(FleetTripModels.ResourceOccupancy::available); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero(); - } - - @Test void failedReassignmentKeepsOldResourcesAndRollsBackTheNewVehicleClaim() { - long oldVehicle = vehicle(true), occupiedVehicle = vehicle(true), replacement = vehicle(true); - long oldDriver = driver(true), occupiedDriver = driver(true); - String first = trip(), second = trip(); - service.assign(first, "1", new FleetTripModels.Assign(0L, oldVehicle, oldDriver, "安排")); - service.assign(second, "1", new FleetTripModels.Assign(0L, occupiedVehicle, occupiedDriver, "安排")); - assertThatThrownBy(() -> service.assign(first, "1", new FleetTripModels.Assign(1L, replacement, occupiedDriver, "改派"))).hasMessageContaining("占用"); - var retained = service.detail(first).trip(); - assertThat(retained.vehicleId()).isEqualTo(Long.toString(oldVehicle)); - assertThat(retained.recordVersion()).isEqualTo(1); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, replacement)).isZero(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(4); - } - - @Test void missingCapacityOrUnapprovedDriverCannotBeAssigned() { - String number = trip(); - long unknownCapacity = vehicle(false), validDriver = driver(true); - assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, unknownCapacity, validDriver, "安排"))).hasMessageContaining("托位、载重和容积"); - long validVehicle = vehicle(true), missingLicense = driver(false); - assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, validVehicle, missingLicense, "安排"))).hasMessageContaining("驾驶证"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); - assertThat(service.detail(number).trip().recordVersion()).isZero(); - } - - @Test void extendingPlanPastLicenseExpiryFailsAndKeepsTheOldPlan() { - String number = trip(); - service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排")); - assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(1L, departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期"))) - .hasMessageContaining("预计任务结束前到期"); - assertThat(service.detail(number).trip().expectedDriverEndAt()).isEqualTo(end); - assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1); - } - - @Test void failedHistoryWriteRollsBackTripAndAllResourceClaims() { - String number = trip(); long vehicle = vehicle(true), driver = driver(true); - jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_assignment CHECK(action <> 'ASSIGN')"); - assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排"))).isInstanceOf(RuntimeException.class); - assertThat(service.detail(number).trip().vehicleId()).isNull(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); - assertThat(service.detail(number).trip().recordVersion()).isZero(); - } - - @Test void activeTripPreventsMasterDataFromChangingPlateCapacityOrStatus() { - long vehicle = vehicle(true); String number = trip(); - service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver(true), "安排")); - assertThatThrownBy(() -> vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L))).hasMessageContaining(number); - var original = vehicles.findById(vehicle).orElseThrow(); - assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, "CHANGED", original.model(), original.fuelConsumptionPer100Km(), - original.mileageKm(), null, null, original.region(), original.ownership(), null, 30, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number); - assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, original.plateNumber(), original.model(), original.fuelConsumptionPer100Km(), - original.mileageKm(), null, null, original.region(), original.ownership(), null, 20, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number); - service.cancel(number, "1", new FleetTripModels.Cancel(1L, "取消")); - assertThat(vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L)).status()).isEqualTo("MAINTENANCE"); - } - - @Test void simultaneousVehicleAssignmentHasExactlyOneWinner() throws Exception { - String first = trip(), second = trip(); long vehicle = vehicle(true), driver1 = driver(true), driver2 = driver(true); - var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); - try { - var a = executor.submit(() -> attempt(start, first, vehicle, driver1)); - var b = executor.submit(() -> attempt(start, second, vehicle, driver2)); - start.countDown(); - assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE'", Integer.class)).isEqualTo(1); - } finally { executor.shutdownNow(); } - } - - @Test void concurrentCreationWithTheSameKeyCreatesOneTripAndOneHistory() throws Exception { - var create = createRequest("concurrent-create"); - var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); - Callable command = () -> { start.await(5, TimeUnit.SECONDS); return service.create("1", create).trip().tripNo(); }; - try { - var first = executor.submit(command); var second = executor.submit(command); start.countDown(); - assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS)); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1); - } finally { executor.shutdownNow(); } - } - - @Test void tripCombinesCustomersAndSynchronizesSnapshotsWithoutChangingTheirOwnershipOrArrivalTime() { - String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); - delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B"); - service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入本车")); - var bound = service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入本车")); - assertThat(bound.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A", "CLIENT-B"); - long replacement = vehicle(true); - var reassigned = service.assign(number, "1", new FleetTripModels.Assign(3L, replacement, driver(true), "更换车辆司机")); - assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(reassigned.trip().vehiclePlate()); - assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(reassigned.trip().vehiclePlate()); - assertThat(jdbc.queryForList("SELECT customer_id FROM ll_delivery_order ORDER BY id", String.class)).containsExactly("CLIENT-A", "CLIENT-B"); - assertThat(jdbc.queryForList("SELECT version FROM ll_delivery_order", Long.class)).containsOnly(2L); - assertThat(jdbc.queryForObject("SELECT expected_arrival_at FROM ll_delivery_order WHERE id=2011", LocalDateTime.class)).isEqualTo(LocalDateTime.of(2030, 1, 1, 12, 0)); - assertThat(service.history(number, 1, 20).records().get(0).snapshot().dispatches()).hasSize(2); - } - - @Test void unbindClearsCurrentSnapshotsButKeepsHistoryAndRequiresUnpickedCargo() { - String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A"); - service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); - assertThatThrownBy(() -> service.cancel(number, "1", new FleetTripModels.Cancel(2L, "取消"))).hasMessageContaining("先解除"); - var tx = new org.springframework.transaction.support.TransactionTemplate(transactions); - assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireUnbound(11); return null; })).hasMessageContaining(number); - assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireCoordinatedDeparture(11); return null; })).hasMessageContaining("统一发车"); - assertThat(execution.gate("92", "DP-11").tripNo()).isEqualTo(number); - var assigned = service.detail(number).trip(); - var actual = new FleetDispatchGateway.Assignment(assigned.vehiclePlate(), assigned.driverName(), assigned.driverPhone()); - assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual, - new FleetDispatchGateway.Assignment("OTHER-PLATE", null, null)); return null; })).hasMessageContaining("统一维护"); - tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual, new FleetDispatchGateway.Assignment(null, null, null)); return null; }); - jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=11"); - assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下"))).hasMessageContaining("已开始执行"); - jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=0 WHERE id=11"); - service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下")); - assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); - assertThat(jdbc.queryForObject("SELECT driver_name FROM ll_delivery_order WHERE id=2011", String.class)).isNull(); - assertThat(service.history(number, 1, 20).records().get(1).snapshot().dispatches()).hasSize(1); - assertThat(service.detail(number).dispatches()).isEmpty(); - service.cancel(number, "1", new FleetTripModels.Cancel(3L, "取消")); - } - - @Test void wrongWarehouseAndStaleVersionCannotBindOrMutateCustomerDocuments() { - String number = assignedTrip(); dispatch(11, "CLIENT-A", "93"); - assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).hasMessageContaining("同一起运仓"); - jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=11"); - assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(0L, "DP-11", "加入"))).hasMessageContaining("已被修改"); - assertThat(service.detail(number).dispatches()).isEmpty(); - assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); - } - - @Test void bindingHistoryFailureRollsBackAssociationAndEveryDownstreamSnapshot() { - String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A"); - jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_binding CHECK(action <> 'BIND_DISPATCH')"); - assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).isInstanceOf(RuntimeException.class); - assertThat(service.detail(number).dispatches()).isEmpty(); - assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1L); - assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull(); - assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_delivery_order WHERE id=2011", String.class)).isNull(); - assertThat(jdbc.queryForObject("SELECT version FROM ll_delivery_order WHERE id=2011", Long.class)).isZero(); - } - - @Test void failedReassignmentOfOneExecutedMemberRollsBackOtherMembersAndResourceClaims() { - String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); - service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); - service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); - String original = service.detail(number).trip().vehiclePlate(); - jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=10 WHERE id=12"); - assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(3L, vehicle(true), driver(true), "改派"))).hasMessageContaining("已开始执行"); - assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(original); - assertThat(service.detail(number).trip().recordVersion()).isEqualTo(3L); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); - assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(3L, departure, end.plusSeconds(60), stops(), null, "延后"))) - .hasMessageContaining("已开始执行"); - } - - @Test void concurrentBindingToDifferentTripsHasExactlyOneWinner() throws Exception { - String first = assignedTrip(), second = assignedTrip(); dispatch(11, "CLIENT-A", "92"); - var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); - try { - var a = executor.submit(() -> bindAttempt(start, first)); var b = executor.submit(() -> bindAttempt(start, second)); start.countDown(); - assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch", Integer.class)).isEqualTo(1); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='BIND_DISPATCH'", Integer.class)).isEqualTo(1); - } finally { executor.shutdownNow(); } - } - - @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.CsvSource({"false,false","false,true","true,false","true,true"}) - // 同时验证保留自有货与解绑全部自有货后的实收重新核对,避免依靠额外货物解开前置环。 - void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic,boolean emptyOwn) { - var source=enableQuote(); - String number=emptyOwn?unpickedPlanningTrip():readyTrip();var basis=execution.capacityPlan(number); - execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); - var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create",quoteReference())); - var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); - var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); - when(incoming.create(eq("1"),any())).thenReturn(awaiting); - jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES('9','WT-EXT','WT-EXT')"); - jdbc.update("INSERT INTO ll_inventory_position VALUES(9001,'9',902,'92',9002,22)"); - jdbc.update("INSERT INTO ll_inventory_balance VALUES(9002,'9',9003,'WT-EXT')"); - jdbc.update("INSERT INTO ll_order VALUES(9003,'9','WAREHOUSE_TRANSFER',NULL)"); - var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start"); - var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request); - assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue(); - verify(incoming,times(1)).create(eq("1"),any()); - assertThat(started.booking().status()).isEqualTo("CONFIRMED"); - var actual=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_CUSTOMER_CONFIRMATION","actual-receipt", - List.of(new FleetBookingIncomingGateway.Pallet("902","PALLET-902",22,new BigDecimal("150"),new BigDecimal("1.5"))),22); - when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual); - when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual); - assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), - List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成"); - assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00"); - when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages())); - var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), - List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt")); - assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22); - assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00"); - assertThat(accepted.booking().quoteSource()).isEqualTo(source); - verify(quoteCandidates,times(2)).lockAndResolve(any(),eq(quoteReference())); - assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); - dispatch(13,"9","92"); - jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')"); - when(incoming.inspect("9","WT-EXT",true)).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages())); - when(planning.resolveIncoming(eq("9"),any(),eq("1"))).thenReturn("DP-13"); - var arrange=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"auto-link-external",null); - var linked=automatic?bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange):bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external")); - if(automatic){assertThat(bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange).replayed()).isTrue();verify(planning,times(1)).resolveIncoming(eq("9"),any(),eq("1"));} - assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13"); - assertThat(execution.capacityPlan(number).planValid()).isFalse(); - assertThat(service.detail(number).dispatches()).hasSize(3); - var recheck=execution.capacityPlan(number); - var planned=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(recheck.recordVersion(),recheck.manifestHash(),List.of(measurement(),new FleetExecutionModels.Measurement("902",1,new BigDecimal("150"),new BigDecimal("1.5"),1)),"关联实收后核对")); - assertThat(planned.capacityPlan().segments().get(0).occupied().palletSpaces()).isEqualTo(2); - assertThat(planned.availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); - if(automatic){var replay=bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange);assertThat(replay.tripRecordVersion()).isEqualTo(linked.tripRecordVersion());assertThat(replay.booking()).isEqualTo(linked.booking());} - service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联")); - assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED"); - assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull(); - // 解除关联后重新核对已有实收,不创建新库存,也不重复预留承运容量。 - var reopenedPlan=execution.capacityPlan(number); - var refreshed=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm( - reopenedPlan.recordVersion(),reopenedPlan.manifestHash(),List.of(measurement()),"解除关联后重新核对")); - var currentBooking=bookingService.list(number).records().get(0); - var receiptAgain=new FleetBookingModels.AcceptReceipt(refreshed.recordVersion(),currentBooking.recordVersion(),actual.receiptHash(), - List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt-again"); - var renewed=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain); - assertThat(renewed.booking().status()).isEqualTo("RECEIVED"); - assertThat(renewed.booking().cargo().packageCount()).isEqualTo(22); - assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain).replayed()).isTrue(); - assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); - if(emptyOwn) { - for(var binding:service.detail(number).dispatches()) - service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(service.detail(number).trip().recordVersion(),binding.dispatchNo(),"重新核对前解除全部实际排车")); - assertThat(service.detail(number).dispatches()).isEmpty(); - assertThat(execution.capacityPlan(number).blockReason()).contains("关联本车自有货"); - } - long movedTripVersion=service.detail(number).trip().recordVersion(); - // 实物换托后源摘要变化且旧测量失效;缺测量拒绝,显式实测后更新快照及容量。 - var moved=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","READY_FOR_DISPATCH","moved-receipt", - List.of(new FleetBookingIncomingGateway.Pallet("903","PALLET-903",22,null,null)),22); - when(incoming.inspect("9","WT-EXT",true)).thenReturn(moved); - when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(moved); - var withoutMeasure=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), - List.of(new FleetBookingModels.PalletSpaces("903",1,null,null)),new BigDecimal("55.00"),"moved-confirm"); - assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withoutMeasure)).hasMessageContaining("实际托盘"); - var overCapacity=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), - List.of(new FleetBookingModels.PalletSpaces("903",31,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-over-capacity"); - assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",overCapacity)).isInstanceOf(ResponseStatusException.class); - assertThat(bookingService.list(number).records().get(0).receipt()).isEqualTo(renewed.booking().receipt()); - var withMeasure=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), - List.of(new FleetBookingModels.PalletSpaces("903",1,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-confirm"); - var measuredAgain=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure); - assertThat(measuredAgain.booking().cargo().weightKg()).isEqualByComparingTo("170"); - assertThat(measuredAgain.booking().cargo().volumeCbm()).isEqualByComparingTo("1.7"); - assertThat(measuredAgain.booking().receipt().source().pallets()).singleElement().satisfies(p->{assertThat(p.warehousePalletId()).isEqualTo("903");assertThat(p.weightKg()).isEqualByComparingTo("170");}); - assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure).replayed()).isTrue(); - } - - @Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() { - String number=readyTrip(); var actual=confirm(number); var basis=execution.capacityPlan(number); - execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); - var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one",null); - var draft=bookingService.create(number,"1",create); - assertThat(bookingService.create(number,"1",create).booking().bookingNo()).isEqualTo(draft.booking().bookingNo()); - assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); - var command=new FleetBookingModels.Command(draft.tripRecordVersion(),draft.booking().recordVersion(),"external-confirm-one",null); - var accepted=bookingService.confirm(number,draft.booking().bookingNo(),"1",command); - assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("500"); - assertThat(bookingService.confirm(number,draft.booking().bookingNo(),"1",command).replayed()).isTrue(); - assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(19); - assertThat(execution.gate("92","DP-11").blockReason()).contains("外接预订"); - assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad()); - var cancel=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"external-cancel-one",null); - var cancelled=bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel); - assertThat(bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel).tripRecordVersion()).isEqualTo(cancelled.tripRecordVersion()); - assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); - assertThat(execution.gate("92","DP-11").canDepart()).isTrue(); - assertThat(service.history(number,1,20).records().stream().filter(row->row.action().equals("BOOKING_CONFIRM")).findFirst().orElseThrow() - .snapshot().externalBookings().get(0).status()).isEqualTo("CONFIRMED"); - } - - @Test void secondExternalBookingCannotOversellAndOwnPlanCannotConsumeCommittedExternalSpace() { - String number=readyTrip();var basis=execution.capacityPlan(number); - execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); - var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first",null)); - bookingService.confirm(number,first.booking().bookingNo(),"1",new FleetBookingModels.Command(first.tripRecordVersion(),0L,"confirm-first",null)); - var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second",null)); - assertThatThrownBy(()->bookingService.confirm(number,second.booking().bookingNo(),"1",new FleetBookingModels.Command(second.tripRecordVersion(),0L,"confirm-second",null))).hasMessageContaining("超过车辆"); - assertThat(bookingService.list(number).records().get(1).status()).isEqualTo("DRAFT"); - var excessive=new FleetExecutionModels.Measurement("901",11,new BigDecimal("100"),new BigDecimal("1"),1); - assertThatThrownBy(()->execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(second.tripRecordVersion(),basis.manifestHash(),List.of(excessive),"增加自有货计划"))).hasMessageContaining("超过车辆"); - assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(9); - var changed = service.updatePlan(number,"1",new FleetTripModels.Plan(second.tripRecordVersion(),departure,end.plusSeconds(60),stops(),null,"调整时间")); - assertThat(execution.capacityPlan(number).capacityPlan()).isNull(); - service.assign(number,"1",new FleetTripModels.Assign(changed.trip().recordVersion(),Long.valueOf(changed.trip().vehicleId()),driver(true),"同车换司机")); - assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("CONFIRMED"); - } - private static FleetBookingModels.Cargo externalCargo(int spaces){return new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD",null,null,null);} - - @Test void bookingSourceIsPersistedSeparatelyFromAgreedPriceAndCanBeExplicitlyCleared() { - var source=enableQuote();String number=assignedTrip();long version=service.detail(number).trip().recordVersion(); - var request=new FleetBookingModels.Create(version,9L,externalCargo(2),"quote-create",quoteReference()); - var created=bookingService.create(number,"1",request); - assertThat(bookingService.list(number).records().get(0).quoteSource()).isEqualTo(source); - assertThat(created.booking().agreedAmount()).isEqualByComparingTo("100.00"); - assertThat(bookingService.create(number,"1",request).replayed()).isTrue(); - verify(quoteCandidates,times(1)).lockAndResolve(any(),eq(quoteReference())); - assertThatThrownBy(()->bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"quote-create",null))).hasMessageContaining("同一登记请求"); - var cleared=bookingService.update(number,created.booking().bookingNo(),"1",new FleetBookingModels.Update(created.tripRecordVersion(),0L,externalCargo(2),null)); - assertThat(cleared.booking().quoteSource()).isNull(); - assertThat(bookingService.list(number).records().get(0).quoteSource()).isNull(); - assertThat(jdbc.queryForObject("SELECT quote_source_json FROM ll_fleet_external_booking",String.class)).isNull(); - } - - @Test void referencingOrClearingQuoteRequiresQuoteReadPermissionAndRollsBack() { - String number=assignedTrip();long version=service.detail(number).trip().recordVersion(); - assertThatThrownBy(()->bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"denied-quote",quoteReference()))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("报价读取权限"); - assertThat(bookingService.list(number).records()).isEmpty(); - var source=enableQuote();var created=bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"allowed-quote",quoteReference())); - SecurityContextHolder.clearContext(); - assertThatThrownBy(()->bookingService.update(number,created.booking().bookingNo(),"1",new FleetBookingModels.Update(created.tripRecordVersion(),0L,externalCargo(2),null))).hasMessageContaining("报价读取权限"); - assertThat(bookingService.list(number).records().get(0).quoteSource()).isEqualTo(source); - assertThat(service.detail(number).trip().recordVersion()).isEqualTo(created.tripRecordVersion()); - } - - @Test void confirmRevalidatesQuoteAndDoesNotReserveWhenSourceIsStale() { - var source=enableQuote();String number=readyTrip();var basis=execution.capacityPlan(number); - execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); - var created=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"stale-quote",quoteReference())); - when(quoteCandidates.lockAndResolve(any(),any())).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"报价版本已变化")); - assertThatThrownBy(()->bookingService.confirm(number,created.booking().bookingNo(),"1",new FleetBookingModels.Command(created.tripRecordVersion(),0L,"confirm-stale",null))).hasMessageContaining("报价版本已变化"); - var stored=bookingService.list(number).records().get(0); - assertThat(stored.status()).isEqualTo("DRAFT");assertThat(stored.recordVersion()).isZero();assertThat(stored.quoteSource()).isEqualTo(source); - assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29); - verify(quoteContext,times(2)).resolve(any(),eq(9L),eq("FBA:ONT8"),eq("USD"),eq("PALLET")); - } - - @Test void capacityCanBePlannedBeforePickingWithoutCreatingActualLoadOrApprovals() { - String number = readyTrip(); - jdbc.update("DELETE FROM ll_dispatch_pda_pick"); - jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=0"); - jdbc.update("UPDATE ll_dispatch_pda_task SET status='PDA_PENDING'"); - jdbc.update("UPDATE ll_fleet_trip_dispatch SET review_json=NULL"); - var basis = execution.capacityPlan(number); - assertThat(basis.planValid()).isFalse(); assertThat(basis.blockReason()).isNull(); - var plan = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "计划载荷")); - assertThat(plan.planValid()).isTrue(); - assertThat(plan.capacityPlan().pallets()).hasSize(1); - assertThat(plan.capacityPlan().segments().get(0).remaining().palletSpaces()).isEqualTo(29); - assertThat(execution.loading(number).confirmedLoad()).isNull(); - assertThat(execution.gate("92", "DP-11").canPick()).isFalse(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick", Integer.class)).isZero(); - assertThat(service.history(number, 1, 20).records().get(0).snapshot().capacityPlan()).isEqualTo(plan.capacityPlan()); - var unchanged = service.updatePlan(number, "1", new FleetTripModels.Plan(plan.recordVersion(), departure, end, stops(), "备注", "补充备注")); - assertThat(execution.capacityPlan(number).planValid()).isTrue(); - service.assign(number, "1", new FleetTripModels.Assign(unchanged.trip().recordVersion(), vehicle(true), driver(true), "更换资源")); - assertThat(execution.capacityPlan(number).capacityPlan()).isNull(); - } - - @Test void capacityPlanRejectsStaleCargoAndOverbookingWithoutAffectingConfirmedActualLoad() { - String number = readyTrip(); var actual = confirm(number); var basis = execution.capacityPlan(number); - var tooHeavy = new FleetExecutionModels.Measurement("901", 1, new BigDecimal("19000"), new BigDecimal("1"), 1); - assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(tooHeavy), "预估"))).hasMessageContaining("超过车辆"); - var planned = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "预估")); - assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad()); - assertThat(service.detail(number).loadingReview().status()).isEqualTo("APPROVED"); - jdbc.update("UPDATE ll_dispatch_item SET package_count=11 WHERE id=3011"); - assertThat(execution.capacityPlan(number).planValid()).isFalse(); - assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(planned.recordVersion(), basis.manifestHash(), List.of(measurement()), "旧页面"))).hasMessageContaining("货物明细已变化"); - assertThat(service.detail(number).trip().recordVersion()).isEqualTo(planned.recordVersion()); - } - - @Test void dispatchReviewsAreIndependentAndRejectionBlocksOnlyNewPickingForThatCustomer() { - String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); - service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); - service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); - var tx = new org.springframework.transaction.support.TransactionTemplate(transactions); - assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; })).hasMessageContaining("调度审核"); - service.reviewDispatch(number, "DP-11", "1", new FleetTripModels.Review(3L, "APPROVED", null)); - var gates = execution.gates("92", List.of("DP-11", "DP-12")); - assertThat(gates.get("DP-11").canPick()).isTrue(); - assertThat(gates.get("DP-12").canPick()).isFalse(); - tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; }); - assertThatThrownBy(() -> service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", " "))).hasMessageContaining("打回原因"); - service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", "核对预约时间")); - assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(12); return null; })).hasMessageContaining("核对预约时间"); - service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(5L, "APPROVED", null)); - assertThat(service.detail(number).dispatches()).allMatch(row -> "APPROVED".equals(row.review().status())); - } - - @Test void loadingReviewRequiresActualLoadAndRemarkOnlyEditPreservesApprovals() { - String number = readyTrip(); var loading = execution.loading(number); - assertThatThrownBy(() -> execution.reviewLoading(number, "1", new FleetTripModels.Review(loading.recordVersion(), "APPROVED", null))).hasMessageContaining("确认本车实际装载"); - var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "核对装载")); - assertThat(execution.gate("92", "DP-11").blockReason()).contains("理货审核"); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "before-review", null))).hasMessageContaining("理货审核"); - var rejected = execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "REJECTED", "重新核对重量")); - assertThat(execution.gate("92", "DP-11").blockReason()).contains("重新核对重量"); - var approved = execution.reviewLoading(number, "1", new FleetTripModels.Review(rejected.trip().recordVersion(), "APPROVED", null)); - var changed = service.updatePlan(number, "1", new FleetTripModels.Plan(approved.trip().recordVersion(), departure, end, stops(), "补充备注", "更新备注")); - assertThat(changed.loadingReview().status()).isEqualTo("APPROVED"); - assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); - execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(changed.trip().recordVersion(), loading.manifestHash(), List.of(measurement()), "重新测量")); - assertThat(service.detail(number).loadingReview().status()).isEqualTo("PENDING"); - assertThat(execution.gate("92", "DP-11").canDepart()).isFalse(); - } - - @Test void actualLoadingCountsSharedPhysicalPalletOnceAndDepartureShipsAllCustomersAtomically() { - String number = readyTrip(); - assertThat(execution.gate("92", "DP-11").canDepart()).isFalse(); - var loading = confirm(number); - assertThat(loading.confirmedLoad().cargo()).hasSize(2); - assertThat(loading.confirmedLoad().segments().get(0).occupied().palletSpaces()).isEqualTo(1); - assertThat(loading.confirmedLoad().segments().get(0).occupied().weightKg()).isEqualByComparingTo("100"); - assertThat(execution.gates("92", List.of("DP-11", "DP-12")).values()).allMatch(FleetExecutionModels.Gate::canDepart); - assertThat(execution.gate("92", "DP-11").driverWarning()).contains("有效期覆盖本次预计任务"); - var request = new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "整车发车"); - var result = execution.depart("92", "DP-11", "1", request); - assertThat(result.shippedPackages()).isEqualTo(20); - assertThat(result.dispatches()).extracting(FleetExecutionModels.DepartedDispatch::dispatchNo).containsExactly("DP-11", "DP-12"); - assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("DEPARTED"); - assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("OUTBOUNDED"); - assertThat(jdbc.queryForObject("SELECT COUNT(DISTINCT depart_at) FROM ll_dispatch_order", Integer.class)).isEqualTo(1); - assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); - assertThat(result.departedAt().getNano() % 1_000_000).isZero(); - assertThat(service.detail(number).trip().departedAt()).isEqualTo(result.departedAt()); - assertThat(service.history(number, 1, 20).records().get(0).snapshot().trip().departedAt()).isEqualTo(result.departedAt()); - assertThat(execution.depart("92", "DP-12", "1", request).replayed()).isTrue(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'", Integer.class)).isEqualTo(2); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isEqualTo(1); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "changed"))) - .hasMessageContaining("内容不一致"); - assertThat(service.history(number, 1, 20).records().get(0).snapshot().confirmedLoad().cargo()).hasSize(2); - } - - @Test void reassigningPickedTripPreservesWorkAndRequiresFreshLoadingOnTheReplacement() { - String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); - var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id"); - var tasksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id"); - var arrivals = jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id"); - long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456"); - var changed = service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "发车前换车及司机,重新核对装载")); - assertThat(changed.trip().status()).isEqualTo("PLANNED"); - assertThat(changed.trip().vehicleId()).isEqualTo(Long.toString(replacement)); - assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(changed.trip().vehiclePlate()); - assertThat(jdbc.queryForList("SELECT driver_name FROM ll_delivery_order", String.class)).containsOnly("替班司机"); - assertThat(jdbc.queryForList("SELECT driver_phone FROM ll_delivery_order", String.class)).containsOnly("456"); - assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); - assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id")).isEqualTo(tasksBefore); - assertThat(jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id")).isEqualTo(arrivals); - assertThat(execution.loading(number).confirmedLoad()).isNull(); - assertThat(changed.dispatches()).allMatch(row -> "PENDING".equals(row.review().status())); - assertThat(changed.loadingReview().status()).isEqualTo("PENDING"); - assertThat(execution.gate("92", "DP-11").blockReason()).contains("调度审核"); - approveDispatches(number); - assertThat(execution.gate("92", "DP-11").blockReason()).contains("确认本车实际装载"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class, - Long.parseLong(original.vehicleId()), Long.parseLong(original.driverId()))).isZero(); - var oldLoad = service.history(number, 1, 20).records().stream().filter(row -> row.action().equals("CONFIRM_LOAD")).findFirst().orElseThrow(); - assertThat(oldLoad.snapshot().trip().vehicleId()).isEqualTo(original.vehicleId()); - assertThat(oldLoad.snapshot().confirmedLoad()).isNotNull(); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "old-before-reassign", null))).hasMessageContaining("已被修改"); - var reconfirmed = confirm(number); - assertThat(execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(reconfirmed.recordVersion(), "after-reassign", null)).shippedPackages()).isEqualTo(20); - assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); - } - - @Test void partialPickingAllowsResourceAndTimeChangesButKeepsRouteAndBindingsFrozen() { - String number = readyTrip(); var original = service.detail(number).trip(); - jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=5 WHERE id=12"); - jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING',completed_at=NULL WHERE dispatch_order_id=12"); - jdbc.update("UPDATE ll_dispatch_pda_pick SET packages=5 WHERE id=12"); - var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id"); - var changed = service.assign(number, "1", new FleetTripModels.Assign(original.recordVersion(), vehicle(true), driver(true), "发车前更换资源")).trip(); - var delayed = service.updatePlan(number, "1", new FleetTripModels.Plan(changed.recordVersion(), departure.plusSeconds(60), end.plusSeconds(60), stops(), null, "延后任务时间")).trip(); - assertThat(delayed.expectedDriverEndAt()).isEqualTo(end.plusSeconds(60)); - assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore); - assertThat(jdbc.queryForObject("SELECT status FROM ll_dispatch_pda_task WHERE dispatch_order_id=12", String.class)).isEqualTo("PICKING"); - assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(delayed.recordVersion(), "DP-12", "移走已拣单"))).hasMessageContaining("已开始"); - var differentStops = List.of(stops().get(0), new FleetTripModels.Stop("另一目的地", "新地址", "FBA:LAX9")); - assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(delayed.recordVersion(), departure, end, differentStops, null, "更改路线"))).hasMessageContaining("已开始"); - assertThat(service.detail(number).trip().stops()).isEqualTo(stops()); - assertThat(service.detail(number).dispatches()).hasSize(2); - } - - @Test void replacingConfirmedLoadWithInsufficientCapacityOrExpiredTaskCoverageChangesNothing() { - String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); - long small = vehicle(true); var current = vehicles.findById(small).orElseThrow(); - vehicleService.update(small, "1", new VehicleModels.Update(0L, current.plateNumber(), current.model(), current.fuelConsumptionPer100Km(), current.mileageKm(), null, null, - current.region(), current.ownership(), null, 30, new BigDecimal("50"), new BigDecimal("90"))); - assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), small, driver(true), "容量不足换车"))).hasMessageContaining("已确认装载"); - assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(loaded.recordVersion(), departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期跨过证照到期"))).hasMessageContaining("预计任务结束前到期"); - assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId()); - assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion()); - assertThat(execution.loading(number).confirmedLoad()).isNotNull(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, small)).isZero(); - } - - @Test void reassignmentHistoryFailureRestoresOriginalClaimsSnapshotsAndLoad() { - String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); - long replacement = vehicle(true), replacementDriver = driver(true); - jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_reassignment CHECK(operator_id <> 'reject-operator')"); - assertThatThrownBy(() -> service.assign(number, "reject-operator", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "更换车辆司机"))) - .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); - assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId()); - assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion()); - assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(original.vehiclePlate()); - assertThat(execution.loading(number).confirmedLoad()).isNotNull(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class, replacement, replacementDriver)).isZero(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); - } - - @Test void concurrentReassignmentAndDepartureCannotMixOldAndNewResources() throws Exception { - String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip(); - long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456"); - var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); - try { - var assign = executor.submit(() -> { - start.await(5, TimeUnit.SECONDS); - try { - service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "临时改派")); - return true; - } catch (org.springframework.web.server.ResponseStatusException stale) { - assertThat(stale.getStatusCode().value()).isEqualTo(409); return false; - } - }); - var depart = executor.submit(() -> { - start.await(5, TimeUnit.SECONDS); - try { - execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "race-with-reassign", null)); - return true; - } catch (org.springframework.web.server.ResponseStatusException stale) { - assertThat(stale.getStatusCode().value()).isEqualTo(409); return false; - } - }); - start.countDown(); - boolean assigned = assign.get(15, TimeUnit.SECONDS), departed = depart.get(15, TimeUnit.SECONDS); - assertThat(List.of(assigned, departed)).containsExactlyInAnyOrder(true, false); - var result = service.detail(number).trip(); - assertThat(result.vehicleId()).isEqualTo(assigned ? Long.toString(replacement) : original.vehicleId()); - assertThat(result.status()).isEqualTo(departed ? "IN_TRANSIT" : "PLANNED"); - assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(result.vehiclePlate()); - assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(result.vehiclePlate()); - assertThat(jdbc.queryForObject("SELECT SUM(shipped_package_count) FROM ll_dispatch_order", Integer.class)).isEqualTo(departed ? 20 : 0); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, - Long.parseLong(result.vehicleId()))).isEqualTo(1); - if (assigned) assertThat(execution.loading(number).confirmedLoad()).isNull(); - } finally { executor.shutdownNow(); } - } - - @Test void physicalLoadingRejectsDuplicateMissingOverweightAndWrongDestinationMeasurements() { - String number = readyTrip(); var loading = execution.loading(number); var measurement = measurement(); - assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement, measurement), "核对"))) - .hasMessageContaining("重复计量"); - assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(), "核对"))) - .hasMessageContaining("每个实物托盘"); - assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), - List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("18001"), new BigDecimal("1"), 1)), "核对"))).hasMessageContaining("超过车辆"); - assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), - List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 2)), "核对"))).hasMessageContaining("卸货站"); - jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12"); - var mixed = execution.loading(number); - assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(mixed.recordVersion(), mixed.manifestHash(), List.of(measurement), "核对"))) - .hasMessageContaining("不同卸货目的地"); - } - - @Test void changedCargoAndUnreadyMemberBlockTheEntireVehicleBeforeAnyDepartureWrite() { - String number = readyTrip(); var confirmed = confirm(number); - jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12"); - assertThat(execution.gate("92", "DP-11").blockReason()).contains("货物发生变化"); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "changed-cargo", null))) - .hasMessageContaining("货物发生变化"); - jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='ONT8' WHERE dispatch_order_id=12"); - jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING' WHERE dispatch_order_id=12"); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "unready-member", null))) - .hasMessageContaining("DP-12"); - assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART"); - assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); - } - - @Test void secondCustomerFailureRollsBackFirstCustomerOutboundAndAllReplayRecords() { - String number = readyTrip(); var confirmed = confirm(number); - jdbc.execute("ALTER TABLE ll_dispatch_order ADD CONSTRAINT fail_second_depart CHECK(id <> 12 OR status <> 'DEPARTED')"); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "rollback-whole-trip", null))) - .isInstanceOf(RuntimeException.class); - assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART"); - assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isZero(); - assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); - assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); - } - - @Test void dispatchDepartureRechecksDriverAndRejectsWrongWarehouseAndStaleVersion() { - String number = readyTrip(); var confirmed = confirm(number); - assertThatThrownBy(() -> execution.depart("93", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "wrong-warehouse", null))) - .hasMessageContaining("当前绑定仓"); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(0L, "stale-version", null))) - .hasMessageContaining("已被修改"); - jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1))); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "expired-driver", null))) - .isInstanceOf(ResponseStatusException.class); - assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); - } - - @Test void simultaneousWholeTripDepartureReplaysOnceWithoutDoubleShipping() throws Exception { - String number = readyTrip(); var confirmed = confirm(number); - var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "concurrent-departure", null); - var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2); - Callable action = () -> { start.await(5, TimeUnit.SECONDS); return execution.depart("92", "DP-11", "1", request).replayed(); }; - try { - var a = executor.submit(action); var b = executor.submit(action); start.countDown(); - assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(false, true); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isEqualTo(2); - } finally { executor.shutdownNow(); } - } - - /** 取消与实际整车发车并发时,已拣货车只能发车;出库和派送不得产生取消终态。 */ - @Test void outboundCancellationRacingDepartureCannotCreateDepartedCancelledCargo() throws Exception { - String number=readyTrip();var loaded=confirm(number); - var cancel=new com.baiyue.lastleg.delivery.service.DeliveryTransferService(); - var config=new MybatisConfiguration();config.addMapper(OutboundOrderMapper.class);config.addMapper(DispatchOrderMapper.class); - var source=(javax.sql.DataSource)jdbc.getDataSource(); - var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(source);factory.setConfiguration(config);factory.afterPropertiesSet(); - var data=new SqlSessionTemplate(factory.getObject()); - ReflectionTestUtils.setField(cancel,"outboundOrders",data.getMapper(OutboundOrderMapper.class)); - ReflectionTestUtils.setField(cancel,"dispatchOrders",data.getMapper(DispatchOrderMapper.class)); - ReflectionTestUtils.setField(cancel,"fleetBindings",bindingGuard); - var start=new CountDownLatch(1);var executor=Executors.newFixedThreadPool(2); - try { - var departure=executor.submit(()->{start.await(5,TimeUnit.SECONDS);return execution.depart("92","DP-11","1",new FleetExecutionModels.Depart(loaded.recordVersion(),"race-depart","并发取消验证"));}); - var cancellation=executor.submit(()->{start.await(5,TimeUnit.SECONDS);try{ - new org.springframework.transaction.support.TransactionTemplate(transactions).executeWithoutResult(s->cancel.cancelOutbound("OUT-11","1",new com.baiyue.lastleg.delivery.dto.DeliveryRequests.Cancel("并发取消"))); - return false; - }catch(ResponseStatusException rejected){return rejected.getStatusCode().value()==409;}}); - start.countDown();assertThat(departure.get(20,TimeUnit.SECONDS).shippedPackages()).isEqualTo(20);assertThat(cancellation.get(20,TimeUnit.SECONDS)).isTrue(); - assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsOnly("OUTBOUNDED"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE status='CANCELLED'",Integer.class)).isZero(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'",Integer.class)).isEqualTo(2); - } finally {executor.shutdownNow();} - } - - @Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() { - String number = readyTrip(); var confirmed = confirm(number); - long driverId = Long.parseLong(service.detail(number).trip().driverId()); - var current = drivers.detail(driverId, end); - var original = current.details(); - var domestic = new DriverModels.Draft(original.name(), original.phone(), original.ownership(), "DOMESTIC", - original.licenseNumber(), original.licenseType(), original.licenseIssuedOn(), original.licenseExpiresOn(), - original.licenseZone(), original.remark(), original.profile()); - var changed = drivers.update(driverId, "1", new DriverModels.Update(current.recordVersion(), domestic)); - assertThat(drivers.detail(driverId, end).qualification().canDispatch()).isTrue(); - - var gate = execution.gate("92", "DP-11"); - assertThat(gate.canDepart()).isFalse(); - assertThat(gate.blockReason()).contains("海外司机"); - var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "driver-region-check", null); - assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", request)).hasMessageContaining("海外司机"); - assertThat(service.detail(number).trip().recordVersion()).isEqualTo(confirmed.recordVersion()); - assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED"); - assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero(); - - drivers.update(driverId, "1", new DriverModels.Update(changed.recordVersion(), original)); - assertThat(execution.gate("92", "DP-11").canDepart()).isTrue(); - assertThat(execution.depart("92", "DP-11", "1", request).status()).isEqualTo("IN_TRANSIT"); - } - - @Test void cargoCompletionDoesNotReleaseResourcesAndEachTaskEndsIndependently() { - String number = departedTrip(); markDeliveriesSigned(); - var before = service.detail(number).trip(); - var request = new FleetCompletionModels.Complete(before.recordVersion(), "complete-cargo", "全部货物已处理完"); - var completed = completion.complete(number, "1", request).trip(); - assertThat(completed.status()).isEqualTo("COMPLETED"); - assertThat(completed.completedAt()).isNotNull(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); - assertThat(completion.complete(number, "1", request).trip().recordVersion()).isEqualTo(completed.recordVersion()); - assertThat(service.history(number, 1, 20).records().get(0).snapshot().completion().outcomes()).hasSize(2); - var vehicleEnded = completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "release-vehicle", "车辆检修")).trip(); - assertThat(vehicleEnded.vehicleRelease().vehicleStatus()).isEqualTo("MAINTENANCE"); - assertThat(vehicleEnded.driverRelease()).isNull(); - assertThat(vehicles.findById(Long.parseLong(vehicleEnded.vehicleId())).orElseThrow().status()).isEqualTo("MAINTENANCE"); - assertThat(jdbc.queryForObject("SELECT resource_kind FROM ll_fleet_resource_assignment", String.class)).isEqualTo("DRIVER"); - jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1))); - var driverEnded = completion.release(number, "1", new FleetCompletionModels.Release(vehicleEnded.recordVersion(), "DRIVER", Instant.now(), "交接点", null, "release-driver", "返程任务结束")).trip(); - assertThat(driverEnded.driverRelease()).isNotNull(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero(); - } - - @Test void incompleteOrCancelledDeliveryDoesNotCountAsCargoCompletion() { - String number = departedTrip(); - assertThat(completion.inspection(number).canComplete()).isFalse(); - markDeliveriesSigned(); - jdbc.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=2012"); - assertThatThrownBy(() -> completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "incomplete-cargo", "核对"))).hasMessageContaining("DP-12"); - assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); - jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9,signed_packages=9 WHERE id=2012"); - assertThat(completion.inspection(number).blockReason()).contains("数量未覆盖"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); - } - - @Test void returnedStatusRequiresImmutableReturnCompletionEvidence() { - String number = departedTrip(); markDeliveriesSigned(); - jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL,signed_packages=NULL WHERE id=2012"); - assertThat(completion.inspection(number).canComplete()).isFalse(); - LocalDateTime returnedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS); - jdbc.update("INSERT INTO ll_delivery_operation(id,customer_id,delivery_order_id,operation_node,result,created_at) VALUES(1,'CLIENT-B',2012,'RETURN_COMPLETED','退回已完成',?)", returnedAt); - returnedCargo(10, returnedAt); - jdbc.update("UPDATE ll_delivery_order SET updated_at=? WHERE id=2012", returnedAt.plusDays(1)); - assertThat(completion.inspection(number).canComplete()).isTrue(); - var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "return-completion", "签收及退回已核对")).trip(); - assertThat(completed.completedAt()).isEqualTo(returnedAt.atZone(ZoneId.systemDefault()).toInstant()); - } - - @Test void laterReturnRequestDoesNotReopenAlreadySignedOriginalTransport() { - String number = departedTrip(); markDeliveriesSigned(); - var signedAt = jdbc.queryForObject("SELECT signed_at FROM ll_delivery_order WHERE id=2012", LocalDateTime.class); - jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING' WHERE id=2012"); - var inspection = completion.inspection(number); - assertThat(inspection.canComplete()).isTrue(); - assertThat(inspection.outcomes().get(1).status()).isEqualTo("DELIVERED"); - assertThat(inspection.outcomes().get(1).terminalAt()).isEqualTo(signedAt.atZone(ZoneId.systemDefault()).toInstant()); - completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "original-transport-complete", "原运输签收已完成")); - assertThat(jdbc.queryForObject("SELECT status FROM ll_delivery_order WHERE id=2012", String.class)).isEqualTo("RETURN_PENDING"); - } - - @Test void releaseChecksCompletionTimeAndRollsBackVehicleStatusAndClaimWhenHistoryFails() { - String number = departedTrip(); - assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(), "VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "premature-release", "结束"))) - .hasMessageContaining("先核对全部货物"); - markDeliveriesSigned(); - var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "ready-completion", "核对")).trip(); - assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", completed.completedAt().minusSeconds(1), "仓库", "IN_SERVICE", "early-release", "结束"))) - .hasMessageContaining("实际任务结束时间"); - assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "DRIVER", Instant.now().plusSeconds(60), "仓库", null, "future-release", "结束"))) - .hasMessageContaining("实际任务结束时间"); - jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT fail_release CHECK(action <> 'RELEASE_VEHICLE')"); - assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "failed-release", "维修"))) - .isInstanceOf(RuntimeException.class); - assertThat(vehicles.findById(Long.parseLong(completed.vehicleId())).orElseThrow().status()).isEqualTo("IN_SERVICE"); - assertThat(service.detail(number).trip().vehicleRelease()).isNull(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); - } - - @Test void oldReleaseRetryCannotReleaseAResourceAssignedToTheNextTrip() { - String number = departedTrip(); markDeliveriesSigned(); - var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "completion-replay", "核对")).trip(); - var release = new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "下一起运点", "IN_SERVICE", "vehicle-release-replay", "结束本次车辆任务"); - completion.release(number, "1", release); - String next = trip(); service.assign(next, "1", new FleetTripModels.Assign(0L, Long.parseLong(completed.vehicleId()), driver(true), "下一任务")); - completion.release(number, "1", release); - assertThat(jdbc.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=?", String.class, Long.parseLong(completed.vehicleId()))).isEqualTo(next); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='RELEASE_VEHICLE'", Integer.class)).isEqualTo(1); - } - - private String departedTrip() { - String number = readyTrip(); var loading = confirm(number); - execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-for-completion", null)); - return number; - } - private void markDeliveriesSigned() { - jdbc.update("UPDATE ll_outbound_order SET package_count=10"); - jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_packages=10,signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS)); - } - - private void returnedCargo(int quantity, LocalDateTime at) { - jdbc.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,package_count) VALUES(6001,'CLIENT-B',1012,901,10)"); - jdbc.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,exception_type,status,reason,delivery_order_id,last_mile_order_id,exception_at) VALUES(6002,'CLIENT-B','EX-RETURN','RETURN','COMPLETED','真实退回',2012,6102,?)", at); - jdbc.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id,max_return_packages,approved_return_packages,received_packages,created_at) VALUES(6003,'CLIENT-B',6002,6001,4012,10,?,?,?)", quantity, quantity, at); - jdbc.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,receipt_id,order_id,packages) VALUES(6005,'CLIENT-B',12,6103,6102,10)"); - jdbc.update("INSERT INTO ll_inventory_ledger(id,customer_id,customer_name,balance_id,overseas_warehouse_id,inbound_order_id,business_type,business_order_no,outbound_order_no,source_event_id,idempotency_key,before_packages,delta_packages,after_packages,occurred_at) VALUES(6004,'CLIENT-B','客户B',6101,'92',6102,'RETURN_STOCK_IN','EX-RETURN','OUT-12','RETURN_SOURCE:6001:4012','receipt-unique',0,?,?,?)", quantity, quantity, at); - } - - @Test void partialReturnCannotReleaseResourcesAndResidualSignatureClosesOnlyConservedQuantities() { - String number = departedTrip(); markDeliveriesSigned(); - jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING',signed_at=NULL,signed_packages=NULL WHERE id=2012"); - returnedCargo(4, LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS)); - assertThat(completion.inspection(number).canComplete()).isFalse(); - assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(), - "VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "partial-no-release", "仍有余货"))).hasMessageContaining("结案"); - var signedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS); - jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED',signed_at=?,signed_packages=6 WHERE id=2012", signedAt); - var inspection = completion.inspection(number); - assertThat(inspection.canComplete()).isTrue(); - var outcome = inspection.outcomes().stream().filter(row -> row.deliveryNo().equals("DL-12")).findFirst().orElseThrow(); - assertThat(outcome.packages()).isEqualTo(10); assertThat(outcome.signedPackages()).isEqualTo(6); - assertThat(outcome.returnedPackages()).isEqualTo(4); assertThat(outcome.remainingPackages()).isZero(); - completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "partial-true-completion", "实退及余货实签核平")); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); - } - - /** 未拣货的真实自有货计划,允许通过正式命令解绑全部排车。 */ - private String unpickedPlanningTrip() { - String number=assignedTrip();dispatch(11,"CLIENT-A","92");dispatch(12,"CLIENT-B","92"); - service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(1L,"DP-11","加入")); - service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(2L,"DP-12","加入")); - for(long id:List.of(11L,12L)) - jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id) VALUES(?,?,?,901,'PALLET-901',10,'RECEIVED','ONT8','92')",id+3000,id,id==11?"CLIENT-A":"CLIENT-B"); - return number; - } - private String readyTrip() { - String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); - delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B"); - service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入")); - service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入")); - approveDispatches(number); - for (long id : List.of(11L, 12L)) { - String customer = id == 11 ? "CLIENT-A" : "CLIENT-B"; - jdbc.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=10 WHERE id=?", id); - jdbc.update("UPDATE ll_dispatch_pda_task SET status='COMPLETED' WHERE dispatch_order_id=?", id); - jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id) VALUES(?,?,?,901,'PALLET-901',10,'RECEIVED','ONT8','92')", id + 3000, id, customer); - jdbc.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages,product_quantity,idempotency_key,picked_at) VALUES(?,?,?,?,?,10,0,?,CURRENT_TIMESTAMP)", id, customer, id, id + 3000, id + 4000, "pick-" + id); - } - return number; - } - private FleetExecutionModels.Loading confirm(String number) { - var loading = execution.loading(number); - var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "实际装载核对")); - execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "APPROVED", null)); - return execution.loading(number); - } - private void approveDispatches(String number) { - for (var binding : service.detail(number).dispatches()) service.reviewDispatch(number, binding.dispatchNo(), "1", - new FleetTripModels.Review(service.detail(number).trip().recordVersion(), "APPROVED", null)); - } - private static FleetExecutionModels.Measurement measurement() { return new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 1); } - - private boolean bindAttempt(CountDownLatch start, String number) throws Exception { - start.await(5, TimeUnit.SECONDS); - try { service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "并发关联")); return true; } - catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; } - } - private String assignedTrip() { - String number = trip(); service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排")); return number; - } - private void dispatch(long id, String customer, String warehouse) { - jdbc.update("INSERT INTO ll_dispatch_order(id, customer_id, dispatch_no, overseas_warehouse_id, package_count) VALUES(?,?,?,?,10)", id, customer, "DP-" + id, warehouse); - jdbc.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,task_no,status,warehouse_id,pushed_at) VALUES(?,?,?,?,'PDA_PENDING',?,CURRENT_TIMESTAMP)", - id, customer, id, "TASK-" + id, warehouse); - } - private void delivery(long dispatchId, String customer) { - jdbc.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,idempotency_key) VALUES(?,?,?,?,?)", - dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId); - jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)", - dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0)); - jdbc.update("UPDATE ll_outbound_order SET package_count=10 WHERE id=?", dispatchId + 1000); - jdbc.update("UPDATE ll_delivery_order SET package_count=10 WHERE id=?", dispatchId + 2000); - } - - private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception { - start.await(5, TimeUnit.SECONDS); - try { service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "并发安排")); return true; } - catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; } - } - private String trip() { return service.create("1", createRequest(UUID.randomUUID().toString())).trip().tripNo(); } - private FleetTripModels.Create createRequest(String key) { return new FleetTripModels.Create(92L, departure, end, stops(), null, key); } - private static List stops() { return List.of(new FleetTripModels.Stop("起运仓", "提货地址", null), new FleetTripModels.Stop("派送仓", "送货地址", "FBA:ONT8")); } - private long vehicle(boolean capacity) { - return vehicles.create(new VehicleRepository.NewVehicle("TEST-" + UUID.randomUUID(), "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), capacity ? 30 : null, capacity ? new BigDecimal("18000") : null, capacity ? new BigDecimal("90") : null)); - } - private long driver(boolean approve) { return driver(approve, "司机", "123"); } - private long driver(boolean approve, String name, String phone) { - var draft = new DriverModels.Draft(name, phone, "OWNED", "OVERSEAS", "LIC-" + UUID.randomUUID(), "Class A", null, LocalDate.now(ZoneOffset.UTC).plusDays(30), "UTC", null, null); - var driver = drivers.create("1", draft); long id = Long.parseLong(driver.id()); - if (approve) { - drivers.uploadLicense(id, 0, "1", new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7".getBytes())); - drivers.review(id, "1", new DriverModels.Review(1L, "APPROVED", null)); - } - return id; - } - private T transactional(T target, Class type) { - var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); - proxy.addAdvice(new TransactionInterceptor(transactions, new AnnotationTransactionAttributeSource())); - return type.cast(proxy.getProxy()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java index baad0af40..bb6089ba9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java @@ -50,7 +50,8 @@ public class VehicleFulfillmentQueryTest { jdbc.execute("CREATE ALIAS JSON_CONTAINS FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonContains'"); jdbc.execute("CREATE ALIAS JSON_SET FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonSet'"); jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))"); - jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT UNIQUE)"); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))"); jdbc.execute("CREATE TABLE ll_fleet_trip_handover(id BIGINT PRIMARY KEY,trip_id BIGINT,trip_version BIGINT,snapshot_json CLOB,UNIQUE(trip_id,trip_version))"); jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)"); @@ -61,8 +62,9 @@ public class VehicleFulfillmentQueryTest { when(vehicles.findById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class))); service = new VehicleFulfillmentService(vehicles, mapper, JSON); jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-1',8,'PLANNED')"); - jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1,'CUSTOMER-A'),(12,1,'CUSTOMER-B')"); - delivery(21,11,"CUSTOMER-A"); delivery(22,12,"CUSTOMER-B"); + jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1)"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,11,'CUSTOMER-A'),(2,11,'CUSTOMER-B')"); + delivery(21,11,"CUSTOMER-A"); delivery(22,11,"CUSTOMER-B"); } private void delivery(long deliveryId, long dispatchId, String customer) { jdbc.update("INSERT INTO ll_delivery_order(id,delivery_no,dispatch_no,dispatch_order_id,customer_id,customer_name,destination_type,destination_warehouse_code,status,created_at) VALUES(?,?,?,?,?,?,'FBA','ONT8','WAIT_DELIVERY',TIMESTAMP '2026-10-03 00:00:00')", @@ -72,7 +74,7 @@ public class VehicleFulfillmentQueryTest { var trip = new HashMap(); if (vehicle != null) { trip.put("vehicleId",vehicle); trip.put("vehiclePlate","PLATE-"+vehicle); } if (driver != null) { trip.put("driverId",driver); trip.put("driverName","司机"+driver); } - String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11"),Map.of("dispatchNo","DP-12")))); + String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11")))); jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(?,1,?,TIMESTAMP '2026-10-03 00:00:00',?,'更换车辆',?)",version+1,version,action,snapshot); } @Test void currentPlanUsesStableVehicleIdentityAndCustomerScopedDispatchJoin() { @@ -108,7 +110,7 @@ public class VehicleFulfillmentQueryTest { assertThat(in.records().get(0).relationship()).isEqualTo("REPLACED_IN"); assertThat(out.records().get(0).previousVehiclePlate()).isEqualTo("PLATE-7"); assertThat(out.records().get(0).vehiclePlate()).isEqualTo("PLATE-8"); - assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11","DP-12"); + assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11"); assertThat(out.records().get(0).occurredAt().toString()).isEqualTo("2026-10-03T00:00:00Z"); assertThat(service.changes(77,new VehicleFulfillmentModels.Query(1,10)).total()).isZero(); } @@ -127,14 +129,14 @@ public class VehicleFulfillmentQueryTest { jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='IN_TRANSIT'"); String snapshot = JSON.writeValueAsString(Map.of("replacement", Map.of("vehicleId", "9"), "carriedDeliveries", List.of(Map.of("customerId", "CUSTOMER-B", "deliveryNo", "DL-22")), - "cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-12"), Map.of("dispatchNo", "DP-12"))))); + "cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-11"), Map.of("dispatchNo", "DP-11"))))); jdbc.update("INSERT INTO ll_fleet_trip_handover VALUES(1,1,1,?)", snapshot); assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)).records()).hasSize(2); assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).records()) .extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-22"); assertThat(service.changes(9,new VehicleFulfillmentModels.Query(1,10)).records()).singleElement().satisfies(change -> { assertThat(change.relationship()).isEqualTo("REPLACED_IN"); - assertThat(change.dispatchNos()).containsExactly("DP-12"); + assertThat(change.dispatchNos()).containsExactly("DP-11"); }); jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED' WHERE id=22"); assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isEqualTo(1); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java index a1952e1e3..401c9e65d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InboundPlanDispatchStatusPersistenceTest { private JdbcTemplate db; private InboundPlanQueryMapper mapper; + private com.baiyue.lastleg.mapper.LastlegOrderMapper orders; @BeforeEach void setUp() throws Exception { @@ -31,30 +32,33 @@ class InboundPlanDispatchStatusPersistenceTest { db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)"); - db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),status VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30))"); db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)"); db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)"); db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')"); var config = new MybatisConfiguration(); config.addMapper(InboundPlanQueryMapper.class); + config.addMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); - mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundPlanQueryMapper.class); + var session = new SqlSessionTemplate(factory.getObject()); + mapper = session.getMapper(InboundPlanQueryMapper.class); + orders = session.getMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); } @Test void cancelledDispatchRetainsHistoryButDoesNotMarkOrFilterTheInboundAsScheduled() { - dispatch(10, "A", "CANCELLED", "A"); + dispatch(10, "CANCELLED", "BEFORE_LOADING", "A"); assertStatus("UNSCHEDULED"); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item", Integer.class)).isEqualTo(1); } @Test void replacementDispatchCountsOnceAndCancellingItRestoresUnscheduled() { - dispatch(10, "A", "CANCELLED", "A"); - dispatch(11, "A", "PENDING_DEPARTURE", "A"); - dispatch(12, "A", "PICKING", "A"); + dispatch(10, "CANCELLED", "BEFORE_LOADING", "A"); + dispatch(11, "ACTIVE", "BEFORE_LOADING", "A"); + dispatch(12, "ACTIVE", "LOADING", "A"); assertStatus("SCHEDULED"); db.update("UPDATE ll_dispatch_order SET status='CANCELLED'"); assertStatus("UNSCHEDULED"); @@ -62,16 +66,15 @@ class InboundPlanDispatchStatusPersistenceTest { @Test void departedAndCompletedDispatchesRemainScheduled() { - dispatch(10, "A", "DEPARTED", "A"); + dispatch(10, "ACTIVE", "DELIVERING", "A"); assertStatus("SCHEDULED"); - db.update("UPDATE ll_dispatch_order SET status='COMPLETED'"); + db.update("UPDATE ll_dispatch_order SET stage='DELIVERED'"); assertStatus("SCHEDULED"); } @Test void otherCustomerOrMissingDispatchCannotMarkThisInboundScheduled() { - dispatch(10, "B", "PENDING_DEPARTURE", "B"); - dispatch(11, "B", "PENDING_DEPARTURE", "A"); + dispatch(10, "ACTIVE", "BEFORE_LOADING", "B"); db.update("INSERT INTO ll_dispatch_item VALUES(12,'A',999,3)"); assertStatus("UNSCHEDULED"); assertThat(mapper.rows("B", query(null), 0, 20)).isEmpty(); @@ -106,12 +109,14 @@ class InboundPlanDispatchStatusPersistenceTest { assertThat(remaining.appointmentNo()).isEqualTo("AP-FIRST");assertThat(remaining.appointmentStatus()).isEqualTo("SUCCESS"); } - private void dispatch(long id, String owner, String status, String itemOwner) { - db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, owner, status); + private void dispatch(long id, String status, String stage, String itemOwner) { + db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage); db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id); } private void assertStatus(String status) { + assertThat(orders.findDispatchStatuses(java.util.List.of(3L))) + .extracting(com.baiyue.lastleg.mapper.LastlegOrderMapper.DispatchStatusRow::dispatchStatus).containsExactly(status); for (String customer : new String[]{null, "A"}) { assertThat(mapper.rows(customer, query(null), 0, 20)) .extracting(InboundPlanListResponse::dispatchStatus).containsExactly(status); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java index 4adc78fa9..d151bd1a4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java @@ -4,6 +4,7 @@ import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; class InboundPlanQueryMapperContractTest { @Test @@ -20,7 +21,7 @@ class InboundPlanQueryMapperContractTest { assertTrue(sql.contains("p.overseas_warehouse_id overseasWarehouseId")); assertTrue(sql.contains("p.overseas_warehouse_id=#{q.overseasWarehouseId}")); assertTrue(sql.contains("di.customer_id=p.customer_id AND di.order_id=l.order_id")); - assertTrue(sql.contains("dispatch.customer_id=di.customer_id")); + assertFalse(sql.contains("dispatch.customer_id")); assertTrue(sql.contains("dispatch.status<>'CANCELLED'")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java index b4b205f47..fe5ad2907 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java @@ -38,9 +38,10 @@ class AppointmentWorkPreArrangementPersistenceTest { doReturn(List.of(new AppointmentWorkDispatchMapper.Candidate(101L,"P-B",11L,"IP-B","B","RECEIVED",false),new AppointmentWorkDispatchMapper.Candidate(102L,"P-D",12L,"IP-D","D","RECEIVED",false))).when(mapper).candidates(batch.id()); doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,60))).when(mapper).physical(101L,"B"); doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D"); - var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,customer_id,status) VALUES(20,'B','PENDING_DEPARTURE')"); + var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,record_version,status) VALUES(20,'DP-B',0,'ACTIVE')"); + jdbc.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,source_stage,package_count) VALUES(201,'B',20,101,'RECEIVED',60)"); var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources); - var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store); + var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store,candidates); var tx=new TransactionTemplate(context.getBean(PlatformTransactionManager.class)); var before=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow(); assertTrue(before.blockReasons().isEmpty());assertEquals(60,before.packageCount()); @@ -48,24 +49,26 @@ class AppointmentWorkPreArrangementPersistenceTest { batch=receiving.plan("operator",new Plan("pending-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("PENDING"),"transport-v1")).batches().get(0); assertEquals("PENDING",batch.appointmentStatus());assertEquals(originalB,store.plan(batch.batchKey(),"B")); var after=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow(); - assertEquals(before,after);assertBindable(tx,binding,mapper,after); + assertEquals(before.packageCount(),after.packageCount());assertEquals(before.blockReasons(),after.blockReasons()); + assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());assertBindable(tx,binding,mapper,after); var bound=new AppointmentWorkDispatchMapper.Bound(20L,201L,101L,batch.batchKey(),"B","ORDER:1","FBA:ONT8",60); - doReturn(List.of(bound)).when(mapper).bindings("DP-B");doReturn(List.of()).when(mapper).dispatchPhysical("DP-B"); + doReturn(List.of(bound)).when(mapper).bindings("DP-B"); assertTrue(assertThrows(ResponseStatusException.class,()->binding.requireDeparture("DP-B")).getReason().contains("尚无有效收货确认")); // Unchanged real-source guards still reject an unverified pallet and an allocation owned by another customer. doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,null))).when(mapper).physical(101L,"B"); assertFalse(candidates.read(batch.batchKey()).candidates().get(0).blockReasons().isEmpty()); - assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),after.allocations()))); - assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("D",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),after.allocations()))); + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),after.allocations()))); + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"D",101L,"RECEIVED",60)),after.allocations()))); doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",61,61))).when(mapper).physical(101L,"B"); var excessive=candidates.read(batch.batchKey()).candidates().get(0);assertFalse(excessive.blockReasons().isEmpty()); - assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",61)),excessive.allocations()))); + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",61)),excessive.allocations()))); assertTrue(mapper.byDispatch(20).isEmpty()); } } private static void assertBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) { tx.executeWithoutResult(status->{ - binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),candidate.allocations()); + binding.lockBatches(Map.of(candidate.allocations().get(0).batchKey(),candidate.allocations().get(0).recordVersion())); + binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),candidate.allocations()); assertEquals(60,mapper.byDispatch(20).stream().mapToInt(AppointmentWorkDispatchMapper.Bound::packages).sum()); status.setRollbackOnly(); // Compare identical physical availability before and after the independent customer's plan command. }); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index 48402799f..bac6bc6e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -212,8 +212,8 @@ class FbaAppointmentPersistenceTest { String sql=baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";");statement.execute(sql); } statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0,customer_id VARCHAR(128),customer_name VARCHAR(128))");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)"); - statement.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT)"); - statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(128),status VARCHAR(32))"); + statement.execute("CREATE TABLE ll_dispatch_item(id BIGINT,order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT,plan_pallet_id BIGINT,source_stage VARCHAR(32),package_count INT)"); + statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR(64),record_version BIGINT,status VARCHAR(32))"); String work;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);} for(var sql:work.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql); String receiving;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-receiving.sql")){receiving=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);} @@ -239,8 +239,8 @@ class FbaAppointmentPersistenceTest { @Bean FbaReceivingStore receivingStore(FbaReceivingMapper mapper,ObjectMapper json){return new FbaReceivingStore(mapper,json);} @Bean FbaAppointmentTransportPort receivingTransport(){ var port=mock(FbaAppointmentTransportPort.class);var scope=new FbaAppointmentTransportPort.Scope("transport-v1",List.of(),List.of()); - when(port.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString())).thenReturn(scope); - when(port.apply(any(),anyList(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port; + when(port.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString(),anyString())).thenReturn(scope); + when(port.apply(any(),anyList(),anyString(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port; } @Bean FbaAppointmentWorkPort receivingWork(){ var port=mock(FbaAppointmentWorkPort.class); @@ -250,7 +250,7 @@ class FbaAppointmentPersistenceTest { @Bean FbaAppointmentExecutionPort receivingExecution(){ var port=mock(FbaAppointmentExecutionPort.class); org.mockito.stubbing.Answer scope=c->{List members=c.getArgument(0);return new FbaAppointmentExecutionPort.Scope("transport-v1",members.stream().map(m->new FbaAppointmentExecutionPort.Eligibility(m,false,true,null)).toList());}; - when(port.inspect(anyList())).thenAnswer(scope);when(port.lockAndInspect(anyList(),anyString())).thenAnswer(scope);return port; + when(port.inspect(anyList())).thenAnswer(scope);when(port.lockAndInspect(anyList(),anyString(),anyString())).thenAnswer(scope);return port; } @Bean FbaReceivingService receivingService(FbaReceivingStore store,InboundFbaAppointmentService batches,FbaAppointmentTransportPort transport,FbaAppointmentCommandMapper commands,FbaAppointmentWorkPort work,FbaAppointmentExecutionPort execution){return new FbaReceivingService(store,batches,transport,commands,work,execution);} @Bean AppointmentCargoVerificationMapper verifications(SqlSessionTemplate session){return session.getMapper(AppointmentCargoVerificationMapper.class);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java index 7a9ce6b62..947a80973 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java @@ -165,7 +165,7 @@ class FbaAppointmentRepartitionPersistenceTest { try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class)); var original=service.createFromSources("operator",create("create")); - db.update("INSERT INTO ll_dispatch_order VALUES(10,'A','DRAFT')");db.update("INSERT INTO ll_dispatch_item VALUES(1,'A',10)"); + db.update("INSERT INTO ll_dispatch_order(id,status) VALUES(10,'ACTIVE')");db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id) VALUES(11,'A',10,12)"); db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,11,12,?,'A','ORDER:1','FBA:ONT8',1)",original.batchKey()); assertThrows(ResponseStatusException.class,()->service.repartition("operator",split(original,"blocked"))); db.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE id=10"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java index 7111b3aa1..ee88828e4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java @@ -33,7 +33,7 @@ class FbaReceivingPersistenceTest { assertFalse(f.store.hasPending(original.batchKey(),"A")); assertEquals(1,f.receiving.context(original.batchKey()).pendingChanges().size()); f.assertAllocations(80,160,40); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); var withdrawn=f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw",0,"WITHDRAW",null,TRANSPORT)); assertEquals("WITHDRAWN",withdrawn.status()); assertFalse(f.store.hasPending(original.batchKey(),"B")); @@ -65,7 +65,7 @@ class FbaReceivingPersistenceTest { assertEquals(List.of(20,40,100),destinations.stream().flatMap(b->b.items().stream()).filter(i->i.customerId().equals("B")).map(FbaAppointmentResponse.Item::packageCount).sorted().toList()); f.assertAllocations(80,160,40); assertEquals(0,f.db.queryForObject("SELECT SUM(version) FROM ll_order",Integer.class)); - verify(f.transport).apply(any(),eq(List.of(new FbaAppointmentTransportPort.Move(original.batchKey(),"B","ORDER:2",DESTINATION,160,100,later.batchKey()))),eq(OPERATOR),anyString()); + verify(f.transport).apply(any(),eq(List.of(new FbaAppointmentTransportPort.Move(original.batchKey(),"B","ORDER:2",DESTINATION,160,100,later.batchKey()))),eq(OPERATOR),anyString(),eq("partial-confirm")); } } @@ -102,7 +102,7 @@ class FbaReceivingPersistenceTest { conflict(()->f.receiving.resolve(OPERATOR,first.changeKey(),new Resolve("same-resolve",0,"WITHDRAW",null,TRANSPORT))); assertEquals(2,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class)); assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_change",Integer.class)); - verify(f.transport,times(1)).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,times(1)).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -111,7 +111,7 @@ class FbaReceivingPersistenceTest { try(var f=new Fixture()) { var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B"); var pending=f.propose(original,100,"rollback-propose"); - when(f.transport.apply(any(),anyList(),anyString(),anyString())).thenThrow(new IllegalStateException("physical adjustment rejected")); + when(f.transport.apply(any(),anyList(),anyString(),anyString(),anyString())).thenThrow(new IllegalStateException("physical adjustment rejected")); assertThrows(IllegalStateException.class,()->f.confirm(pending.changeKey(),"retry-resolve")); assertEquals(original,f.batches.detail(original.appointmentNo())); assertEquals(group,f.store.currentAgreement(original.batchKey(),"B")); @@ -119,7 +119,7 @@ class FbaReceivingPersistenceTest { assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class)); assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='retry-resolve'",Integer.class)); f.assertAllocations(80,160,40); - doReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of())).when(f.transport).apply(any(),anyList(),anyString(),anyString()); + doReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of())).when(f.transport).apply(any(),anyList(),anyString(),anyString(),anyString()); assertEquals("APPLIED",f.confirm(pending.changeKey(),"retry-resolve").status()); f.assertAllocations(80,160,40); } @@ -171,7 +171,7 @@ class FbaReceivingPersistenceTest { conflict(()->f.confirm(pending.changeKey(),"stale-batch")); assertEquals("WAITING",f.store.change(pending.changeKey(),false).status()); assertEquals(280,total(f.batches.detail(original.appointmentNo())));f.assertAllocations(80,160,40); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); } } @@ -185,7 +185,7 @@ class FbaReceivingPersistenceTest { conflict(()->f.confirm(pending.changeKey(),"stale-agreement")); assertEquals("WAITING",f.store.change(pending.changeKey(),false).status()); assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); } } @@ -198,14 +198,14 @@ class FbaReceivingPersistenceTest { assertEquals(original,f.batches.detail(original.appointmentNo())); assertEquals("WAITING",f.store.change(pending.changeKey(),false).status()); f.assertAllocations(80,160,40); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); } } @Test void transportScopeConflictCannotCreatePendingChangeOrConsumeCommandKey() { try(var f=new Fixture()) { var original=f.create(); - when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"原车次范围已更新")); + when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"原车次范围已更新")); conflict(()->f.propose(original,100,"stale-transport")); assertFalse(f.store.hasPending(original.batchKey(),"B")); assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='stale-transport'",Integer.class)); @@ -272,7 +272,7 @@ class FbaReceivingPersistenceTest { try(var f=new Fixture()) { var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B"); var pending=f.propose(original,100,"before-picking"); - when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"已开始拣货,禁止调整运输货量")); + when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"已开始拣货,禁止调整运输货量")); conflict(()->f.confirm(pending.changeKey(),"confirm-after-picking")); assertEquals("WAITING",f.store.change(pending.changeKey(),false).status()); conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-wrong-version",1,"WITHDRAW",null,TRANSPORT))); @@ -285,8 +285,8 @@ class FbaReceivingPersistenceTest { assertEquals(group,f.store.currentAgreement(original.batchKey(),"B")); assertEquals(result,f.receiving.resolve(OPERATOR,pending.changeKey(),request)); conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-after-picking",0,"CONFIRM","修改请求内容",TRANSPORT))); - verify(f.transport,times(2)).lockAndInspect(anyList(),eq(TRANSPORT)); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,times(2)).lockAndInspect(anyList(),eq(TRANSPORT),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -300,7 +300,7 @@ class FbaReceivingPersistenceTest { return new FbaAppointmentExecutionPort.Scope("execution-v1",members.stream().map(m->new FbaAppointmentExecutionPort.Eligibility(m,m.customerId().equals("A"),!m.customerId().equals("A"),m.customerId().equals("A")?"本客户货物已经全部签收":null)).toList()); }; when(f.execution.inspect(anyList())).thenAnswer(scope); - when(f.execution.lockAndInspect(anyList(),anyString())).thenAnswer(scope); + when(f.execution.lockAndInspect(anyList(),anyString(),anyString())).thenAnswer(scope); clearInvocations(f.transport); var candidates=f.receiving.groupContext(group.agreementKey()); assertEquals(List.of("B","D"),candidates.eligibleMembers().stream().map(Member::customerId).sorted().toList()); @@ -313,9 +313,9 @@ class FbaReceivingPersistenceTest { assertNull(f.store.currentAgreement(batch.batchKey(),"A")); var rebooked=f.receiving.book(OPERATOR,new Book("remaining-bd",List.of(new Booking(selections(batch,"B","D"),confirmed("REMAINING-BD"),"仓库仅确认未完成客户")),group.agreementKey(),cancelledGroup.version(),candidates.executionScope().version())); assertEquals(List.of("B","D"),rebooked.agreements().get(0).members().stream().map(Member::customerId).sorted().toList()); - verify(f.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1")); - verify(f.transport,never()).lockAndInspect(anyList(),anyString()); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1"),anyString()); + verify(f.transport,never()).lockAndInspect(anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -331,6 +331,9 @@ class FbaReceivingPersistenceTest { assertNull(later.plannedDate());assertNull(later.plannedAt());assertNull(later.confirmationNo()); verify(f.work,times(2)).resolveFollowup("42",followup.nextFollowupDate()); verify(f.work).seedFollowup(new FbaAppointmentWorkPort.Member(original.batchKey(),"B"),new FbaAppointmentWorkPort.Member(later.batchKey(),"B"),followup,OPERATOR); + var businessOrder=inOrder(f.transport,f.work); + businessOrder.verify(f.transport).apply(any(),anyList(),eq(OPERATOR),eq(base.reason()),eq("undated-confirm")); + businessOrder.verify(f.work).seedFollowup(any(),any(),eq(followup),eq(OPERATOR)); var split=f.batches.repartition(OPERATOR,new RepartitionFbaAppointmentsRequest( List.of(new RepartitionFbaAppointmentsRequest.Original(later.appointmentNo(),later.version())),List.of(laterBatch(40),laterBatch(20)),"后送再拆分并继承跟进","inherit-split")); verify(f.work).inheritFollowup(eq(List.of(new FbaAppointmentWorkPort.Member(later.batchKey(),"B"))),eq(split.appointments().stream().map(b->new FbaAppointmentWorkPort.Member(b.batchKey(),"B")).toList()),eq(OPERATOR)); @@ -364,7 +367,7 @@ class FbaReceivingPersistenceTest { assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class)); assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='inactive-confirm'",Integer.class)); verify(f.work,never()).seedFollowup(any(),any(),any(),anyString()); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -413,7 +416,7 @@ class FbaReceivingPersistenceTest { var cancelled=f.cancel(old);var batch=cancelled.batches().get(0);var cancelledGroup=cancelled.agreements().get(0); var member=new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"); var eligibility=new FbaAppointmentExecutionPort.Eligibility(member,false,true,null); - when(f.execution.lockAndInspect(eq(List.of(member)),eq("execution-v1"))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v1",List.of(eligibility))); + when(f.execution.lockAndInspect(eq(List.of(member)),eq("execution-v1"),anyString())).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v1",List.of(eligibility))); when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v2",List.of(eligibility))); var before=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class); conflict(()->f.receiving.book(OPERATOR,new Book("raced-execution",List.of(new Booking(selections(batch,"B"),confirmed("RACED-B"),"并发新关联")),old.agreementKey(),cancelledGroup.version(),"execution-v1"))); @@ -428,7 +431,7 @@ class FbaReceivingPersistenceTest { @Test void transportAssociationChangingBeforeBatchLockRejectsProposalWithoutPendingOrCommand() { try(var f=new Fixture()) { var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B"); - when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of())); + when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of())); when(f.transport.inspect(anyList())).thenReturn(new FbaAppointmentTransportPort.Scope("transport-v2",List.of(),List.of())); conflict(()->f.propose(original,100,"raced-proposal")); assertFalse(f.store.hasPending(original.batchKey(),"B")); @@ -436,7 +439,7 @@ class FbaReceivingPersistenceTest { assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='raced-proposal'",Integer.class)); assertEquals(original,f.batches.detail(original.appointmentNo())); assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B")); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -445,7 +448,7 @@ class FbaReceivingPersistenceTest { try(var f=new Fixture()) { var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"A"); var revisionCount=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision",Integer.class); - when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of())); + when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of())); when(f.transport.inspect(anyList())).thenReturn(new FbaAppointmentTransportPort.Scope("transport-v2",List.of(),List.of())); for(var action:List.of("CANCEL","RESCHEDULE")) { var key="raced-group-"+action; @@ -458,8 +461,8 @@ class FbaReceivingPersistenceTest { assertEquals(revisionCount,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision",Integer.class)); f.assertAllocations(80,160,40); } - verify(f.transport,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT)); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java index 31b7d210f..223e6140f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java @@ -87,7 +87,8 @@ class InboundPlanPaginationTest { assertThat(sql).contains("p.customer_id=#{customerId}", "o.customer_id=p.customer_id"); assertThat(sql).doesNotContain("a2.customer_id=#{customerId}"); assertThat(sql).contains("di.customer_id=p.customer_id AND di.order_id=l.order_id", - "dispatch.customer_id=di.customer_id", "dispatch.status<>'CANCELLED'"); + "dispatch.id=di.dispatch_order_id", "dispatch.status<>'CANCELLED'"); + assertThat(sql).doesNotContain("dispatch.customer_id"); assertThat(sql).contains("p.overseas_warehouse_id overseasWarehouseId", "o.overseas_warehouse_code overseasWarehouseCode", "o.overseas_warehouse_name overseasWarehouseName", "p.overseas_warehouse_id=#{q.overseasWarehouseId}"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java index ccd2587bf..e2b18a5d3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java @@ -13,29 +13,31 @@ import static org.mockito.ArgumentMatchers.*; class AppointmentWorkDispatchBindingTest { @Test void referencesCombinePhysicalRowsWithoutLosingBatchCustomerOrSourceIdentity() throws Exception { var mapper=mock(AppointmentWorkDispatchMapper.class); - var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class)); + var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class)); when(mapper.references(List.of(11L,12L))).thenReturn(List.of( - new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",40), - new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",20), - new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:2",null,"A","ORDER:2","FBA:ONT8",10), - new AppointmentWorkDispatchMapper.ReferenceRow(12L,"DISPATCH:12","DP-12","B","ORDER:3","CUSTOMER:3",30))); + new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"A","ORDER:1","FBA:ONT8",40), + new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"A","ORDER:1","FBA:ONT8",20), + new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:2",null,2L,"A","ORDER:2","FBA:ONT8",10), + new AppointmentWorkDispatchMapper.ReferenceRow(12L,"DISPATCH:12","DP-12",3L,"B","ORDER:3","CUSTOMER:3",30))); var result=service.references(List.of(12L,11L));assertEquals(2,result.get(11L).size()); assertEquals(60,result.get(11L).get(0).sources().get(0).packageCount());assertNull(result.get(11L).get(1).batchNo()); assertEquals("DP-12",result.get(12L).get(0).batchNo());assertEquals("B",result.get(12L).get(0).customerId()); + assertEquals(0L,result.get(11L).get(0).recordVersion()); verify(mapper).references(List.of(11L,12L)); var transaction=AppointmentWorkDispatchBinding.class.getMethod("references",List.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class); assertEquals(org.springframework.transaction.annotation.Propagation.SUPPORTS,transaction.propagation());assertTrue(transaction.readOnly()); - when(mapper.references(List.of(11L))).thenReturn(java.util.Collections.nCopies(20001,new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",1))); + when(mapper.references(List.of(11L))).thenReturn(java.util.Collections.nCopies(20001,new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"A","ORDER:1","FBA:ONT8",1))); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.references(List.of(11L))); } @Test void departureRechecksCurrentSourceAllocationAfterBatchQuantityOrMembershipChanges() { var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class); var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class); var receiving=mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class); - var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving); + var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving,mock(AppointmentWorkDispatchCandidates.class)); when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("C",4)); var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4); when(mapper.bindings("D-20")).thenReturn(List.of(row)); + when(mapper.dispatchPhysical("D-20")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4))); when(mapper.lockAppointment(1)).thenReturn(new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment()); var batch=mock(FbaAppointmentResponse.class);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1"); when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.appointmentAt()).thenReturn(java.time.LocalDateTime.of(2026,10,18,10,0)); @@ -82,22 +84,26 @@ class AppointmentWorkDispatchBindingTest { var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",1,customer,List.of(source),null); return new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-1",1,"ACTIVE","FBA:ONT8",details,"America/Los_Angeles",List.of(member),"仓库已确认"); } - @Test void groupedReplayCannotRewriteBatchBindingsAfterTheOriginalDispatchHasMovedOn() { - var mapper=mock(AppointmentWorkDispatchMapper.class);var changes=mock(AppointmentWorkChanges.class); - var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class)); - var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4); - when(mapper.byDispatch(20)).thenReturn(List.of(row)); - var allocation=new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4); - assertDoesNotThrow(()->service.requireReplay("C",20,List.of(allocation))); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireReplay("C",20,List.of(new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:2","C","ORDER:1","FBA:ONT8",4)))); - verify(mapper,never()).remove(anyLong());verify(mapper,never()).insert(any());verifyNoInteractions(changes); + @Test void batchPrelockUsesCanonicalSortedKeysAcceptsZeroAndRejectsStaleVersion() { + var mapper=mock(AppointmentWorkDispatchMapper.class); + var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class)); + var zero=new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment();zero.setVersion(0); + var two=new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment();two.setVersion(2); + when(mapper.lockAppointment(10)).thenReturn(zero);when(mapper.lockAppointment(2)).thenReturn(two); + service.lockBatches(Map.of("FBA_APPOINTMENT:2",2L,"FBA_APPOINTMENT:10",0L)); + var order=inOrder(mapper);order.verify(mapper).lockAppointment(10);order.verify(mapper).lockAppointment(2); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Map.of("FBA_APPOINTMENT:10",1L))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Map.of("FBA_APPOINTMENT:01",0L))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Collections.singletonMap("FBA_APPOINTMENT:10",null))); } @Test void privateDispatchBindsOriginalPhysicalSourceAndDepartureRequiresActualPickedQuantity() { var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class); - var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class)); + var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class)); when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,4))); when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null))); - service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of()); + var physical=new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4); + when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(physical));when(mapper.dispatchPhysical("D-20")).thenReturn(List.of(physical)); + service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of()); var expected=new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","CUSTOMER:1",4); verify(mapper).insert(expected);verify(changes).requirementsChanged(); when(mapper.bindings("D-20")).thenReturn(List.of(expected)); @@ -119,7 +125,7 @@ class AppointmentWorkDispatchBindingTest { @Test void emptyAllocationCannotBypassAnExistingBatchAtArrangeOrDeparture() { var mapper=mock(AppointmentWorkDispatchMapper.class); var appointments=mock(InboundFbaAppointmentService.class); var sources=mock(AppointmentSourceService.class); var changes=mock(AppointmentWorkChanges.class); - var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class)); + var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class)); var part=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4); when(mapper.physical(10,"C")).thenReturn(List.of(part)); when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null))); @@ -127,9 +133,10 @@ class AppointmentWorkDispatchBindingTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch)); when(mapper.bindings("D-1")).thenReturn(List.of()); when(mapper.dispatchPhysical("D-1")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4))); + when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4))); for(var status:List.of("SUCCESS","CANCELLED","FAILED")) { when(batch.appointmentStatus()).thenReturn(status); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of())); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of())); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1")); } verify(mapper,never()).insert(any()); verifyNoInteractions(changes); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java new file mode 100644 index 000000000..d22b4b544 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*; +import com.baiyue.lastleg.inboundplan.dto.*; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实MyBatis、批次行锁、逐客户分配与回滚;原批次和来源读取边界由当前DB事实供给。 */ +class AppointmentWorkDispatchPersistenceTest { + private JdbcTemplate jdbc; + private TransactionTemplate tx; + private AppointmentWorkDispatchPort binding; + private AppointmentWorkDispatchMapper mapper; + private InboundFbaAppointmentQueryMapper query; + private AppointmentWorkDispatchCandidates candidates; + private InboundFbaAppointmentService appointments; + private AppointmentSourceService sources; + private final String batch="FBA_APPOINTMENT:1"; + + /** 无客户主单,多个客户可在同车同批;真实计划逐票数量来自各自明细。 */ + @BeforeEach void setup() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:appointment-dispatch-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager);tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))"); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT)"); + jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)"); + jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))"); + jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(40))"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),dispatch_order_id BIGINT,expires_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE change_guard(id BIGINT PRIMARY KEY,version INT)");jdbc.update("INSERT INTO change_guard VALUES(1,0)"); + jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'D-1',0,'ACTIVE')");jdbc.update("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1',0,NULL)");jdbc.update("INSERT INTO ll_inbound_plan VALUES(1,'PLAN-1')"); + seed(11,"101",1,80,"FBA");seed(21,"202",2,100,"FBA");seed(22,"202",2,60,"FBA");seed(31,"303",3,40,"PRIVATE"); + jdbc.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1,'101','ORDER:1','ONT8',80,1)");jdbc.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,2,'202','BOOKING:real-2','ONT8',160,1)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentWorkDispatchMapper.class);config.addMapper(InboundFbaAppointmentQueryMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); + mapper=session.getMapper(AppointmentWorkDispatchMapper.class);query=session.getMapper(InboundFbaAppointmentQueryMapper.class); + appointments=mock(InboundFbaAppointmentService.class);sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class); + when(appointments.batchContext(batch)).thenAnswer(c->new FbaAppointmentBatchContext(snapshot(),List.of(),List.of(),List.of(),List.of(),List.of())); + when(sources.canonicalOrders(anyList())).thenAnswer(c->{List orders=c.getArgument(0);return orders.stream().map(o->new AppointmentCoverageService.SourceRef(o.customerId(),o.orderId()==2?"BOOKING:real-2":"ORDER:"+o.orderId(),null)).toList();}); + doAnswer(c->{jdbc.update("UPDATE change_guard SET version=version+1 WHERE id=1");return null;}).when(changes).requirementsChanged(); + candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources); + var proxy=new ProxyFactory(new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(FbaReceivingStore.class),candidates));proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy(); + } + /** 插入真实客户、托盘和逐票数量,不从请求猜测。 */ + private void seed(long item,String customer,long order,int count,String destination) { + jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count); + jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); + jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination); + jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count); + } + /** 测试中的原批次边界每次读取当前事务真实行。 */ + private FbaAppointmentResponse snapshot() { + var response=mock(FbaAppointmentResponse.class);when(response.batchKey()).thenReturn(batch);when(response.id()).thenReturn(1L);when(response.version()).thenReturn(jdbc.queryForObject("SELECT version FROM ll_inbound_fba_appointment WHERE id=1",Integer.class));when(response.replacementBatchKeys()).thenReturn(List.of()); + var items=jdbc.query("SELECT * FROM ll_inbound_fba_appointment_item WHERE appointment_id=1 AND active=1",(rs,n)->{ + var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn(rs.getString("customer_id"));when(item.sourceKey()).thenReturn(rs.getString("source_key"));when(item.destinationKey()).thenReturn("FBA:"+rs.getString("fba_warehouse_code"));when(item.packageCount()).thenReturn(rs.getInt("package_count"));return item; + });when(response.items()).thenReturn(items);return response; + } + /** 从本车真实明细构造Provider输入。 */ + private PhysicalItem item(long id) {return mapper.dispatchPhysicalById(1).stream().filter(i->i.itemId()==id).map(i->new PhysicalItem(i.itemId(),i.customerId(),i.planPalletId(),i.sourceStage(),i.packages())).findFirst().orElseThrow();} + /** 选择对应真实来源的完整数量,版本零必须显式传递。 */ + private Allocation allocation(long id) {var i=item(id);return new Allocation(i.planPalletId(),batch,0L,i.customerId(),"202".equals(i.customerId())?"BOOKING:real-2":"ORDER:1","FBA:ONT8",i.packages());} + /** 在来源锁前预锁,再增量绑定指定实物。 */ + private void bind(long... ids) {binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,Arrays.stream(ids).mapToObj(this::item).toList(),Arrays.stream(ids).mapToObj(this::allocation).toList());} + + @Test void incrementallyAddingOtherCustomersAndPrivateCargoPreservesOriginalAllocations() { + tx.executeWithoutResult(s->bind(11));var original=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id"); + tx.executeWithoutResult(s->bind(21,22));tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(item(31)),List.of());}); + assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id='101' ORDER BY dispatch_item_id")); + assertEquals(280,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class)); + assertEquals(1L,query.countActiveDispatches(1L));var references=binding.references(List.of(1L));assertEquals(3,references.get(1L).size());assertTrue(references.get(1L).stream().allMatch(r->r.recordVersion()==0)); + assertEquals("BOOKING:real-2",references.get(1L).stream().filter(r->r.customerId().equals("202")).findFirst().orElseThrow().sources().get(0).sourceKey()); + } + @Test void untouchedSameCustomerAllocationParticipatesInCurrentBatchQuota() { + tx.executeWithoutResult(s->bind(21,22));var original=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id"); + jdbc.update("UPDATE ll_inbound_fba_appointment_item SET package_count=150 WHERE customer_id='202'"); + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(21))); + assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id")); + } + @Test void forgedCustomerAndStaleOrMissingVersionCannotWritePhysicalBindings() { + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(new PhysicalItem(11L,"202",111L,"RECEIVED",80)),List.of());})); + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(item(11)),List.of(new Allocation(111L,batch,null,"101","ORDER:1","FBA:ONT8",80)));})); + jdbc.update("UPDATE ll_inbound_fba_appointment SET version=1 WHERE id=1");assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(11))); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class)); + } + @Test void bindingCannotAcquireUnannouncedBatchAfterSourcePhase() { + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(1,List.of(item(11)),List.of(allocation(11))))); + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of());binding.lockBatches(Map.of(batch,0L));})); + } + @Test void expiredDraftDoesNotBlockCandidateButPhysicalAliasAndFormalClaimDo() { + jdbc.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,111,NULL,'ACTIVE',NULL,DATEADD('SECOND',-30,CURRENT_TIMESTAMP))"); + var result=candidates.read(batch);assertEquals(0,result.version());assertFalse(result.candidates().stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().blockReasons().contains("托盘已被其他排车或草稿占用")); + jdbc.update("UPDATE ll_dispatch_pallet_lock SET plan_pallet_id=NULL,warehouse_pallet_id=1011,expires_at=DATEADD('SECOND',30,CURRENT_TIMESTAMP) WHERE id=1"); + assertTrue(mapper.candidates(1).stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().locked()); + jdbc.update("UPDATE ll_dispatch_pallet_lock SET dispatch_order_id=1,expires_at=NULL WHERE id=1");assertTrue(mapper.candidates(1).stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().locked()); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)); + assertTrue(result.candidates().stream().flatMap(c->c.allocations().stream()).allMatch(a->a.recordVersion()==0)); + } + @Test void laterFailureRollsBackEarlierCustomerAllocationAndGuardWithinSameTransaction() { + assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->{bind(11);bind(21,22);throw new IllegalStateException("后续业务失败");})); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT version FROM change_guard WHERE id=1",Integer.class)); + } + @Test void pageBatchReadUsesRealCustomerCanonicalSourceVersionsAndEffectiveOccupancy() { + jdbc.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,NULL,1021,'ACTIVE',NULL,DATEADD('SECOND',30,CURRENT_TIMESTAMP))"); + var inputs=List.of(new CandidatePhysical("101",111L),new CandidatePhysical("202",121L),new CandidatePhysical("wrong",111L)); + var result=binding.candidateBatches(inputs);var a=result.get(inputs.get(0));var b=result.get(inputs.get(1)); + assertEquals(0L,a.appointmentBatches().get(0).recordVersion());assertFalse(a.occupied());assertTrue(b.occupied()); + assertEquals("BOOKING:real-2",b.appointmentBatches().get(0).sources().get(0).sourceKey());assertEquals(100,b.appointmentBatches().get(0).sources().get(0).packageCount()); + assertFalse(result.get(inputs.get(2)).blockReasons().isEmpty());verify(appointments,never()).batchContext(anyString()); + jdbc.update("UPDATE ll_inbound_fba_appointment SET version=3 WHERE id=1");assertEquals(3L,binding.candidateBatches(inputs).get(inputs.get(0)).appointmentBatches().get(0).recordVersion()); + } + @Test void twoDispatchesCannotConsumeTheSameBatchQuotaConcurrently() throws Exception { + jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");jdbc.update("INSERT INTO ll_dispatch_item VALUES(12,2,'101',111,'RECEIVED',80)"); + var start=new CyclicBarrier(2);var pool=Executors.newFixedThreadPool(2); + try { + var results=new ArrayList>(); + for(long dispatch:List.of(1L,2L)) results.add(pool.submit(()->{start.await(5,TimeUnit.SECONDS);try {tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(dispatch,List.of(new PhysicalItem(dispatch==1?11L:12L,"101",111L,"RECEIVED",80)),List.of(new Allocation(111L,batch,0L,"101","ORDER:1","FBA:ONT8",80)));});return true;}catch(ResponseStatusException conflict){return false;}})); + int applied=0;for(var result:results) if(result.get(15,TimeUnit.SECONDS)) applied++;assertEquals(1,applied);assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class)); + } finally {pool.shutdownNow();} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java index bc761a7d5..8de2daafd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java @@ -61,17 +61,28 @@ class AppointmentWorkDomainMapperTest { var sessions=receiving.sessions(List.of("1","2"));assertEquals(2,sessions.size()); assertEquals(403L,sessions.stream().filter(s->"1".equals(s.getSourceContainerBookingId())).findFirst().orElseThrow().getId()); try(var sql=session.getConnection().createStatement()) { - sql.execute("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(501,'A','D-1','PENDING_DEPARTURE'),(502,'B','D-2','CANCELLED'),(503,'B','D-3','PENDING_DEPARTURE')"); + sql.execute("INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,status,stage,cargo_hash) VALUES(501,'D-1',801,'W','ACTIVE','BEFORE_LOADING','HASH'),(502,'D-2',802,'W','CANCELLED','BEFORE_LOADING','HASH'),(503,'D-3',803,'W','ACTIVE','BEFORE_LOADING','HASH')"); + sql.execute("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,order_id,pallet_no,source_key,source_version,source_snapshot_json,source_stage,package_count) VALUES(601,501,'A',701,101,'P-701','PLAN:701','HASH','{}','RECEIVED',4),(602,501,'B',702,102,'P-702','PLAN:702','HASH','{}','RECEIVED',6),(603,502,'B',703,102,'P-703','PLAN:703','HASH','{}','RECEIVED',2),(604,503,'B',704,102,'P-704','PLAN:704','HASH','{}','RECEIVED',3)"); sql.execute("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by) VALUES(1,'AP-1','W','ONT8','operator'),(2,'AP-2','W','ONT8','operator')"); sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','A','ORDER:101','FBA:ONT8',4),(501,602,702,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(502,603,703,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',2),(503,604,704,'FBA_APPOINTMENT:2','B','ORDER:102','FBA:ONT8',3)"); } session.clearCache(); var references=dispatch.references(List.of(501L,503L)); - assertEquals(2,references.size());assertEquals("A",references.get(0).customerId());assertEquals("AP-1",references.get(0).batchNo());assertEquals(4,references.get(0).packages()); - assertEquals("FBA_APPOINTMENT:2",references.get(1).batchKey());assertEquals("AP-2",references.get(1).batchNo()); // Wrong-customer rows on dispatch 501 must not leak into current references. + // 同车两客户均由真实明细关联;主单没有单一客户归属。 + assertEquals(3,references.size());assertEquals("A",references.get(0).customerId());assertEquals("AP-1",references.get(0).batchNo());assertEquals(4,references.get(0).packages()); + assertEquals(501L,references.get(1).dispatchId());assertEquals("B",references.get(1).customerId());assertEquals("AP-1",references.get(1).batchNo());assertEquals(6,references.get(1).packages()); + assertEquals("FBA_APPOINTMENT:2",references.get(2).batchKey());assertEquals("AP-2",references.get(2).batchNo()); + assertEquals(4,dispatch.allocated("FBA_APPOINTMENT:1","A","ORDER:101","FBA:ONT8",503)); + assertEquals(6,dispatch.allocated("FBA_APPOINTMENT:1","B","ORDER:102","FBA:ONT8",503)); var batchDeliveries=deliveries.batches(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:2"));assertEquals(3,batchDeliveries.size()); assertEquals(deliveries.batch("FBA_APPOINTMENT:1"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:1")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList()); assertEquals(deliveries.batch("FBA_APPOINTMENT:2"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:2")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList()); + // 错客户或错托盘的分配不能借同一主单混入当前明细引用。 + try(var sql=session.getConnection().createStatement()) { + sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(501,602,999,'FBA_APPOINTMENT:1','B','ORDER:FOREIGN','FBA:ONT8',7)"); + } + session.clearCache(); + assertEquals(references,dispatch.references(List.of(501L,503L))); var trips=session.getMapper(AppointmentWorkTripMapper.class);assertTrue(trips.batchTrips("FBA_APPOINTMENT:1").isEmpty());assertTrue(trips.batches(1).isEmpty());assertNull(trips.trip(1)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java index a2c9c1443..6d73f4d0d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inboundplan.work; import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort; import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentExecutionPort.*; import org.junit.jupiter.api.Test; @@ -13,50 +14,60 @@ class FbaAppointmentExecutionAdapterTest { final String batch="FBA_APPOINTMENT:1"; final Member a=new Member(batch,"A"),b=new Member(batch,"B"); final AppointmentWorkDispatchMapper mapper=mock(AppointmentWorkDispatchMapper.class); - final FleetTripMapper trips=mock(FleetTripMapper.class); + final DispatchAppointmentAmendmentPort amendments=mock(DispatchAppointmentAmendmentPort.class); final FleetDispatchGateway dispatches=mock(FleetDispatchGateway.class); - final FbaAppointmentExecutionAdapter adapter=new FbaAppointmentExecutionAdapter(mapper,trips,dispatches); + final FbaAppointmentExecutionAdapter adapter=new FbaAppointmentExecutionAdapter(mapper,amendments,dispatches); final Instant at=Instant.parse("2026-10-01T12:00:00Z"); FbaAppointmentExecutionAdapterTest() { when(mapper.receivingSources(List.of(batch))).thenReturn(List.of(new AppointmentWorkDispatchMapper.ReceivingSource(batch,"A","ORDER:1","FBA:ONT8",100),new AppointmentWorkDispatchMapper.ReceivingSource(batch,"B","ORDER:2","FBA:ONT8",100))); - when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("A","DEPARTED","COMPLETED"),row("B","PENDING_DEPARTURE","PLANNED"))); + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("A","DELIVERING","COMPLETED"),row("B","BEFORE_LOADING","PLANNED"))); when(dispatches.completion("D-A",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-A","100",List.of(new FleetCompletionModels.Outcome("D-A","DL-A","A","ONT8","DELIVERED",100,at,100,0,0)),null)); when(dispatches.completion("D-B",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-B","100",List.of(),"排车出库尚未完成")); when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(100,at,1,List.of())); - when(trips.lockByNo("T-B")).thenReturn(new FleetTripEntity()); } @Test void sameBatchSubsetKeepsTokenAndOnlySelectedCustomerExecutionRowsAreLocked() { var all=adapter.inspect(List.of(a,b));var selected=adapter.inspect(List.of(b)); assertEquals(all.version(),selected.version());assertTrue(all.members().get(0).completed());assertFalse(all.members().get(0).canRebook());assertTrue(selected.members().get(0).canRebook()); - var locked=adapter.lockAndInspect(List.of(b),all.version());assertTrue(locked.members().get(0).canRebook()); - var order=inOrder(trips,dispatches);order.verify(trips).lockByNo("T-B");order.verify(dispatches).completion("D-B",true);order.verify(dispatches).lockForPlanning("D-B"); - verify(trips,never()).lockByNo("T-A");verify(dispatches,never()).completion("D-A",true);verify(dispatches,never()).lockForPlanning("D-A"); + var locked=adapter.lockAndInspect(List.of(b),all.version(),"operator");assertTrue(locked.members().get(0).canRebook()); + var order=inOrder(amendments,dispatches);order.verify(amendments).lockExistingAppointmentDispatches(any(),eq(List.of("D-B")));order.verify(dispatches).completion("D-B",true);order.verify(dispatches).lockForPlanning("D-B"); + verify(dispatches,never()).completion("D-A",true);verify(dispatches,never()).lockForPlanning("D-A"); } @Test void statusCannotReplaceRealProofAndPartialActualCoverageCannotCompleteWholeAllocation() { when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(50,at,1,List.of())); var partial=adapter.inspect(List.of(a)).members().get(0);assertFalse(partial.completed());assertFalse(partial.canRebook()); when(dispatches.terminalEvidence("D-A",false)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"缺少真实出库依据")); var absent=adapter.inspect(List.of(a)).members().get(0);assertFalse(absent.completed());assertFalse(absent.canRebook()); - when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("B","PENDING_DEPARTURE","COMPLETED"))); + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("B","BEFORE_LOADING","COMPLETED"))); var tripOnly=adapter.inspect(List.of(b)).members().get(0);assertFalse(tripOnly.completed());assertFalse(tripOnly.canRebook()); } @Test void actualReturnCompletionExcludesMemberAndSiblingExecutionChangeInvalidatesToken() { var prior=adapter.inspect(List.of(b)); when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(100,at.plusSeconds(60),2,List.of(new FleetDispatchGateway.ReturnedReceipt("11","101",100,at.plusSeconds(60))))); var returned=adapter.inspect(List.of(a)).members().get(0);assertTrue(returned.completed());assertFalse(returned.canRebook()); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(b),prior.version())); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(b),prior.version(),"operator")); verify(dispatches,never()).completion("D-A",true); } @Test void noCandidatesDoesNotGrantAnOperationOrReadOtherBusinessData() { - clearInvocations(mapper,trips,dispatches);assertTrue(adapter.inspect(List.of()).members().isEmpty()); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty")); - verifyNoInteractions(mapper,trips,dispatches); + clearInvocations(mapper,amendments,dispatches);assertTrue(adapter.inspect(List.of()).members().isEmpty()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty","operator")); + verifyNoInteractions(mapper,amendments,dispatches); + } + /** 同车总发运量不能替代本客户的真实签收数量。 */ + @Test void onePhysicalDispatchUsesCustomerEvidenceInsteadOfTheWholeCarShipmentTotal() { + var rows=List.of(new AppointmentWorkDispatchMapper.TransportRow(1,"D-ONE","A","ACTIVE","DELIVERING",2L,200,200,LocalDateTime.of(2026,10,1,12,0),11,101,100,"RECEIVED",batch,"ORDER:1","FBA:ONT8",100,"T-ONE",2L,"IN_TRANSIT",201L,"OUTBOUNDED","COMPLETED"), + new AppointmentWorkDispatchMapper.TransportRow(1,"D-ONE","B","ACTIVE","DELIVERING",2L,200,200,LocalDateTime.of(2026,10,1,12,0),12,102,100,"RECEIVED",batch,"ORDER:2","FBA:ONT8",100,"T-ONE",2L,"IN_TRANSIT",202L,"OUTBOUNDED","COMPLETED")); + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(rows); + when(dispatches.completion("D-ONE",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-ONE","100",List.of(new FleetCompletionModels.Outcome("D-ONE","DL-A","A","ONT8","DELIVERED",100,at,100,0,0),new FleetCompletionModels.Outcome("D-ONE","DL-B","B","ONT8","DELIVERING",100,null,0,0,0)),null)); + when(dispatches.terminalEvidence("D-ONE",false)).thenReturn(new FleetDispatchGateway.TerminalEvidence("D-ONE","100",200,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",100,at,"DELIVERED",1,List.of()),new FleetDispatchGateway.TerminalDelivery("B","DL-B","FBA:ONT8",100,null,"DELIVERING",1,List.of())))); + var scope=adapter.inspect(List.of(a,b));assertTrue(scope.members().get(0).completed());assertFalse(scope.members().get(1).completed());assertFalse(scope.members().get(1).canRebook()); + when(dispatches.terminalEvidence("D-ONE",false)).thenReturn(new FleetDispatchGateway.TerminalEvidence("D-ONE","100",200,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",50,at,"DELIVERED",1,List.of()),new FleetDispatchGateway.TerminalDelivery("B","DL-B","FBA:ONT8",150,at,"DELIVERED",1,List.of())))); + assertFalse(adapter.inspect(List.of(a)).members().get(0).completed()); } private AppointmentWorkDispatchMapper.TransportRow row(String customer,String status,String tripStatus) { - long id="A".equals(customer)?1:2;boolean executed="DEPARTED".equals(status); - return new AppointmentWorkDispatchMapper.TransportRow(id,"D-"+customer,customer,status,executed?100:0,executed?100:0,LocalDateTime.of(2026,10,1,12,0),id+10,id+100,100,"RECEIVED",batch,"ORDER:"+id,"FBA:ONT8",100,"T-"+customer,1L,tripStatus,id+200,executed?"OUTBOUNDED":"WAIT_OUTBOUND",executed?"COMPLETED":"PDA_PENDING"); + long id="A".equals(customer)?1:2;boolean executed="DELIVERING".equals(status); + return new AppointmentWorkDispatchMapper.TransportRow(id,"D-"+customer,customer,"ACTIVE",status,0L,executed?100:0,executed?100:0,LocalDateTime.of(2026,10,1,12,0),id+10,id+100,100,"RECEIVED",batch,"ORDER:"+id,"FBA:ONT8",100,"T-"+customer,1L,tripStatus,id+200,executed?"OUTBOUNDED":"WAIT_OUTBOUND",executed?"COMPLETED":"PDA_PENDING"); } private FleetDispatchGateway.TerminalEvidence proof(int packages,Instant terminal,long version,List returns) { - return new FleetDispatchGateway.TerminalEvidence("D-A","A","100",packages,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",packages,terminal,returns.isEmpty()?"DELIVERED":"RETURNED",version,returns))); + return new FleetDispatchGateway.TerminalEvidence("D-A","100",packages,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",packages,terminal,returns.isEmpty()?"DELIVERED":"RETURNED",version,returns))); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapterTest.java index bd108771c..761664f3a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapterTest.java @@ -1,7 +1,7 @@ package com.baiyue.lastleg.inboundplan.work; -import com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges; -import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentTransportPort; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; @@ -17,87 +17,80 @@ import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -/** Real mapper/transaction checks; original Fleet/Dispatch commands are represented by transactional SQL test doubles. */ +/** 真实Mapper和事务;主流程窄端口使用同事务SQL替身,不代替主流程集成验收。 */ class FbaAppointmentTransportAdapterTest { private JdbcTemplate jdbc; private TransactionTemplate tx; private FbaAppointmentTransportPort adapter; - private FleetTripService fleet; - private FleetDispatchGateway dispatch; + private DispatchAppointmentAmendmentPort amendments; + /** 一车280箱、三个真实客户、唯一PDA;主单与PDA均无customer_id。 */ @BeforeEach void setup() throws Exception { var ds=new DriverManagerDataSource("jdbc:h2:mem:transport-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager); jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))"); - jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),customer_id VARCHAR(40),status VARCHAR(30),package_count INT,picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),status VARCHAR(30),stage VARCHAR(30),record_version BIGINT,package_count INT,picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP)"); jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT)"); jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)"); jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT)"); jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(40),status VARCHAR(30))"); - jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(40),status VARCHAR(30))"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT,status VARCHAR(30))"); + jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(40),version INT)"); + jdbc.execute("CREATE TABLE adjustment_history(parent_key VARCHAR(64),dispatch_no VARCHAR(40),item_id BIGINT)"); jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'T-1',1,'PLANNED')"); - seed(1,"A",80,List.of(80));seed(2,"B",160,List.of(100,60));seed(3,"C",40,List.of(40)); - var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AppointmentWorkDispatchMapper.class);configuration.addMapper(FleetTripMapper.class); - var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration); - var session=new SqlSessionTemplate(factory.getObject()); - fleet=mock(FleetTripService.class);dispatch=mock(FleetDispatchGateway.class);var gateway=dispatch; - when(gateway.lockForPlanning(anyString())).thenAnswer(c->{String number=c.getArgument(0);jdbc.queryForList("SELECT id FROM ll_dispatch_order WHERE dispatch_no=? FOR UPDATE",number);return null;}); - when(fleet.unbindDispatch(anyString(),anyString(),any())).thenAnswer(c->{var command=c.getArgument(2,FleetTripModels.BindingCommand.class);jdbc.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=(SELECT id FROM ll_dispatch_order WHERE dispatch_no=?)",command.dispatchNo());bump();return null;}); - when(fleet.bindDispatch(anyString(),anyString(),any())).thenAnswer(c->{var command=c.getArgument(2,FleetTripModels.BindingCommand.class);jdbc.update("INSERT INTO ll_fleet_trip_dispatch SELECT id,1 FROM ll_dispatch_order WHERE dispatch_no=?",command.dispatchNo());bump();return null;}); - when(fleet.detail("T-1")).thenAnswer(c->{ - var response=mock(FleetTripModels.Response.class);when(response.recordVersion()).thenReturn(jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class)); - var bindings=jdbc.query("SELECT d.* FROM ll_dispatch_order d JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id ORDER BY d.id",(rs,n)->new FleetTripModels.DispatchBinding(rs.getString("dispatch_no"),rs.getString("customer_id"),rs.getInt("package_count"),"operator",null,null,List.of())); - return new FleetTripModels.Detail(response,null,bindings,null,List.of(),null); - }); - when(fleet.cancel(anyString(),anyString(),any())).thenAnswer(c->{jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED',record_version=record_version+1 WHERE id=1");return null;}); - doAnswer(c->{ - long item=c.getArgument(1);String number=c.getArgument(0); - jdbc.update("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_item_id=?",item);jdbc.update("DELETE FROM ll_dispatch_item WHERE id=?",item); - jdbc.update("UPDATE ll_dispatch_order SET package_count=(SELECT SUM(package_count) FROM ll_dispatch_item WHERE dispatch_order_id=ll_dispatch_order.id) WHERE dispatch_no=?",number);return null; - }).when(dispatch).removeItem(anyString(),anyLong()); - var target=new FbaAppointmentTransportAdapter(session.getMapper(AppointmentWorkDispatchMapper.class),session.getMapper(FleetTripMapper.class),gateway,fleet,mock(FleetBookingReservations.class),mock(AppointmentWorkChanges.class)); - var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));adapter=(FbaAppointmentTransportPort)proxy.getProxy(); + jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'D-1','ACTIVE','BEFORE_LOADING',0,280,0,0,CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(1,1)");jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(1,1,'PDA_PENDING')"); + seed(11,"101",1,80);seed(21,"202",2,100);seed(22,"202",2,60);seed(31,"303",3,40); + for(int id=1;id<=3;id++) jdbc.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,0)",id,"AP-"+id); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentWorkDispatchMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var session=new SqlSessionTemplate(factory.getObject());amendments=mock(DispatchAppointmentAmendmentPort.class); + doAnswer(c->{assertEquals("operator",c.getArgument(0,Actor.class).operatorId());for(var number:c.>getArgument(1)) jdbc.queryForList("SELECT id FROM ll_dispatch_order WHERE dispatch_no=? FOR UPDATE",number);return null;}).when(amendments).lockExistingAppointmentDispatches(any(),anyList()); + when(amendments.applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),eq(true))).thenAnswer(this::applyOriginal); + var proxy=new ProxyFactory(new FbaAppointmentTransportAdapter(session.getMapper(AppointmentWorkDispatchMapper.class),amendments));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));adapter=(FbaAppointmentTransportPort)proxy.getProxy(); } - private void seed(long id,String customer,int total,List amounts) { - jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?,'PENDING_DEPARTURE',?,0,0,CURRENT_TIMESTAMP)",id,"D-"+id,customer,total); - jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(?,1)",id); - for(int i=0;i touched=call.getArgument(3);List expected=call.getArgument(4);List next=call.getArgument(5); + assertTrue(next.isEmpty());assertEquals(touched.size(),expected.size()); + for(var item:touched) { + assertEquals("202",item.customerId());assertTrue(expected.stream().anyMatch(a->a.planPalletId().equals(item.planPalletId()) && a.customerId().equals(item.customerId()) && a.packageCount()==item.packages() && a.recordVersion()==0)); + jdbc.update("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=1 AND dispatch_item_id=?",item.itemId());jdbc.update("DELETE FROM ll_dispatch_item WHERE id=? AND dispatch_order_id=1",item.itemId()); + jdbc.update("INSERT INTO adjustment_history VALUES(?,?,?)",command.idempotencyKey(),"D-1",item.itemId()); } + jdbc.update("UPDATE ll_dispatch_order SET package_count=(SELECT SUM(package_count) FROM ll_dispatch_item WHERE dispatch_order_id=1),record_version=record_version+1 WHERE id=1");return null; } - private void bump() { jdbc.update("UPDATE ll_fleet_trip SET record_version=record_version+1 WHERE id=1"); } + /** 插入逐客户真实实物;批次与整车归属独立。 */ + private void seed(long item,String customer,long batch,int count) { + jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?, 'RECEIVED',?)",item,customer,item+100,count); + jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(1,?,?,?,?,?,?,?)",item,item+100,"FBA_APPOINTMENT:"+batch,customer,"ORDER:"+batch,"FBA:ONT8",count); + } + /** 沿原父预约命令执行一次完整实物调整。 */ private FbaAppointmentTransportPort.Result move(int retained) { - var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2")); - return tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version());return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","B","ORDER:2","FBA:ONT8",160,retained,"FBA_APPOINTMENT:4")),"operator","客户明确调整");}); + var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2"));return tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version(),"operator");return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","202","ORDER:2","FBA:ONT8",160,retained,"FBA_APPOINTMENT:4")),"operator","客户明确调整","parent-command");}); } - private int remaining(FbaAppointmentTransportPort.Result result) { return result.vehicles().stream().filter(v->"T-1".equals(v.tripNo())).mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum(); } - @Test void wholeCustomerExitLeavesOtherCustomers120AndReusesOriginalPhysicalDispatch() { - var other=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'B'"); - var result=move(0);assertEquals(120,remaining(result)); - assertEquals(other,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'B'")); - assertEquals(160,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation WHERE batch_key='FBA_APPOINTMENT:4'",Integer.class)); - assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE dispatch_order_id=2",Integer.class));verify(dispatch,never()).removeItem(anyString(),anyLong()); + @Test void wholeCustomerExitLeavesOtherCustomers120OnTheSamePhysicalDispatch() { + var other=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'202' ORDER BY dispatch_item_id");var result=move(0); + assertEquals(120,result.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum());assertEquals(other,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'202' ORDER BY dispatch_item_id")); + assertEquals(120,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class));assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch WHERE dispatch_id=1",Integer.class)); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task WHERE dispatch_order_id=1",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE batch_key='FBA_APPOINTMENT:4'",Integer.class));assertEquals(1L,jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class)); } - @Test void wholePhysical60MovesOutLeavingOriginalVehicle220AndLaterScopeHasNoSyntheticVehicle() { - var result=move(100);assertEquals(220,remaining(result));verify(dispatch).removeItem("D-2",21L); - assertEquals(100,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=2",Integer.class)); - var later=adapter.inspect(List.of("FBA_APPOINTMENT:4"));assertTrue(later.vehicles().isEmpty()); - var next=tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:4"),later.version());return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:4","B","ORDER:2","FBA:ONT8",60,40,"FBA_APPOINTMENT:5")),"operator","后送60分40和20");}); - assertTrue(next.vehicles().isEmpty());assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class)); + @Test void wholePhysical60MovesOutLeavingOriginalVehicle220AndNoSyntheticLaterVehicle() { + var result=move(100);assertEquals(220,result.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum());assertEquals(220,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class)); + assertEquals(List.of(11L,21L,31L),jdbc.queryForList("SELECT id FROM ll_dispatch_item ORDER BY id",Long.class));assertTrue(adapter.inspect(List.of("FBA_APPOINTMENT:4")).vehicles().isEmpty()); } - @Test void unmatchedPartialQuantityAndStaleScopeNeverMutateOriginalPhysicalRows() { - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->move(110)); - assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));verify(dispatch,never()).removeItem(anyString(),anyLong()); - var old=adapter.inspect(List.of("FBA_APPOINTMENT:2"));bump(); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->tx.execute(status->adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),old.version()))); + @Test void sameCarSameBatchHasIndependentCustomerVehiclesAndWholeCarVersionInvalidatesScope() { + jdbc.update("UPDATE ll_dispatch_appointment_allocation SET batch_key='FBA_APPOINTMENT:1'");var scope=adapter.inspect(List.of("FBA_APPOINTMENT:1")); + assertEquals(Set.of("101","202","303"),scope.vehicles().stream().map(FbaAppointmentTransportPort.Vehicle::customerId).collect(java.util.stream.Collectors.toSet()));assertEquals(280,scope.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum()); + jdbc.update("UPDATE ll_dispatch_order SET record_version=record_version+1 WHERE id=1");assertThrows(org.springframework.web.server.ResponseStatusException.class,()->tx.execute(status->adapter.lockAndInspect(List.of("FBA_APPOINTMENT:1"),scope.version(),"operator"))); } - @Test void downstreamFailureRollsBackUnbindingPhysicalRemovalAndQuantityTogether() { - doThrow(new IllegalStateException("原车绑定失败")).when(fleet).bindDispatch(anyString(),anyString(),any()); - assertThrows(IllegalStateException.class,()->move(100)); - assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class)); - assertEquals(160,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=2",Integer.class)); - assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class)); - assertEquals(1L,jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class)); + @Test void unmatchedPartialQuantityNeverMutatesOriginalPhysicalRows() { + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->move(110));assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));verify(amendments,never()).applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),anyBoolean()); + } + @Test void downstreamFailureRollsBackCoreQuantityPhysicalAllocationAndHistoryTogether() { + when(amendments.applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),eq(true))).thenAnswer(c->{applyOriginal(c);throw new IllegalStateException("原主流程末尾失败");});var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2")); + assertThrows(IllegalStateException.class,()->tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version(),"operator");jdbc.update("UPDATE ll_inbound_fba_appointment SET appointment_no='CHANGED' WHERE id=2");return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","202","ORDER:2","FBA:ONT8",160,100,"FBA_APPOINTMENT:4")),"operator","客户明确调整","parent-command");})); + assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));assertEquals(280,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM adjustment_history",Integer.class));assertEquals("AP-2",jdbc.queryForObject("SELECT appointment_no FROM ll_inbound_fba_appointment WHERE id=2",String.class)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java index 50e59fb95..adf3fd34f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java @@ -19,10 +19,10 @@ class DispatchShipmentSourceQueryTest { Connection connection = DriverManager.getConnection("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL"); execute(connection, "CREATE TABLE ll_inventory_ledger (id BIGINT PRIMARY KEY, customer_id VARCHAR, position_id BIGINT, overseas_warehouse_id VARCHAR, sku_code VARCHAR, business_type VARCHAR, delta_packages BIGINT, business_order_no VARCHAR, outbound_order_no VARCHAR, idempotency_key VARCHAR)"); execute(connection, "CREATE TABLE ll_outbound_order (id BIGINT PRIMARY KEY, customer_id VARCHAR, outbound_no VARCHAR, overseas_warehouse_id VARCHAR, status VARCHAR, outbound_type VARCHAR, dispatch_no VARCHAR, dispatch_order_id BIGINT)"); - execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, customer_id VARCHAR, warehouse_id VARCHAR, dispatch_order_id BIGINT)"); + execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, warehouse_id VARCHAR, dispatch_order_id BIGINT UNIQUE)"); execute(connection, "CREATE TABLE ll_dispatch_pda_pick (id BIGINT PRIMARY KEY, customer_id VARCHAR, task_id BIGINT, dispatch_item_id BIGINT, inventory_position_id BIGINT, packages BIGINT, idempotency_key VARCHAR)"); execute(connection, "CREATE TABLE ll_inventory_operation (id BIGINT PRIMARY KEY, customer_id VARCHAR, operation_type VARCHAR, status VARCHAR, idempotency_key VARCHAR)"); - execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'100','WH-1',1)"); + execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'WH-1',1)"); execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (21,'100',11,31,41,8,'pick-1')"); return connection; } @@ -88,9 +88,10 @@ class DispatchShipmentSourceQueryTest { execute(connection, "UPDATE ll_dispatch_pda_pick SET idempotency_key='pick-1', packages=9"); assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); execute(connection, "UPDATE ll_dispatch_pda_pick SET packages=8"); - execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='200'"); + execute(connection, "UPDATE ll_dispatch_pda_pick SET customer_id='200'"); assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); - execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='100',warehouse_id='WH-2'"); + execute(connection, "UPDATE ll_dispatch_pda_pick SET customer_id='100'"); + execute(connection, "UPDATE ll_dispatch_pda_task SET warehouse_id='WH-2'"); assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); execute(connection, "UPDATE ll_dispatch_pda_task SET warehouse_id='WH-1',dispatch_order_id=2"); assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); @@ -103,6 +104,22 @@ class DispatchShipmentSourceQueryTest { } } + /** 一车一任务承载两客户,发运恢复只认各自实际扫码和完成的库存命令。 */ + @Test + void sharedTaskPreservesIndependentCustomerShipmentSources() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (22,'200',11,32,42,5,'pick-2')"); + execute(connection, "INSERT INTO ll_inventory_operation VALUES (72,'200','SHIP','COMPLETED','DISPATCH:DS-1:32:pick-2:SHIP')"); + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (52,'200',42,'WH-1','SKU-2','SHIP',-5,'DS-1','DS-1','DISPATCH:DS-1:32:pick-2:SHIP:72')"); + execute(connection, "INSERT INTO ll_outbound_order VALUES (62,'200','OB-2','WH-1','OUTBOUNDED','DISPATCH_DELIVERY','DS-1',1)"); + assertEquals(8, shipped(connection,"100",41,"OB-1","SKU-1")); + assertEquals(5, shipped(connection,"200",42,"OB-2","SKU-2")); + assertEquals(0, shipped(connection,"100",42,"OB-2","SKU-2")); + assertEquals(0, shipped(connection,"200",41,"OB-1","SKU-1")); + } + } + @Test void independentOutboundUsesItsOwnNumberAndDoesNotAcceptADispatchAlias() throws Exception { try (Connection connection = database()) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java index b08b236c2..08b33c9cb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java @@ -122,7 +122,7 @@ class InventoryHoldConcurrencyPersistenceTest { } private void scan(Runnable afterStock) { tx.executeWithoutResult(ignored -> { - attribution.preparePicking("C1",21,null); + attribution.preparePicking("C1",21); inventory.allocate("C1","picker",move(21,5,"scan-allocate")); inventory.pick("C1","picker",move(21,5,"scan-pick")); inventory.ship("C1","picker",move(21,5,"scan-ship")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java new file mode 100644 index 000000000..7de29d251 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java @@ -0,0 +1,382 @@ +package com.baiyue.lastleg.inventory.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import org.h2.api.Trigger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** 实际 Mapper、H2 行锁及 Spring 事务验证共同串行点,不以 mock 调用顺序代替并发证据。 */ +public class PhysicalPalletSerialPersistenceTest { + private static final Map> INSERT_ORDER = new ConcurrentHashMap<>(); + private JdbcTemplate jdbc; + private TransactionTemplate transaction; + private PhysicalPalletSerialRepository repository; + private PhysicalPalletSerialService service; + private ExecutorService workers; + private String observationKey; + + /** 隔离表严格只有实托主键与首次创建时间,触发器仅记录实际数据库插入顺序。 */ + @BeforeEach + void prepare() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:physical-serial-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute(""" + CREATE TABLE ll_physical_pallet_guard ( + warehouse_pallet_id BIGINT PRIMARY KEY, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)) + """); + observationKey = "PHYSICAL_ORDER_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT); + INSERT_ORDER.put(observationKey, Collections.synchronizedList(new ArrayList<>())); + jdbc.execute("CREATE TRIGGER " + observationKey + " BEFORE INSERT ON ll_physical_pallet_guard " + + "FOR EACH ROW CALL '" + GuardInsertOrderTrigger.class.getName() + "'"); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(PhysicalPalletSerialMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(PhysicalPalletSerialMapper.class); + repository = new PhysicalPalletSerialRepository(mapper); + var manager = new DataSourceTransactionManager(ds); + transaction = new TransactionTemplate(manager); + var target = new PhysicalPalletSerialService(repository); + var proxy = new ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource())); + service = (PhysicalPalletSerialService) proxy.getProxy(); + workers = Executors.newFixedThreadPool(2); + } + + /** 释放失败测试中的等待任务并移除本数据库的观测记录,避免跨用例共享状态。 */ + @AfterEach + void cleanup() throws InterruptedException { + if (workers != null) { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS), "并发测试线程未退出"); + } + if (observationKey != null) INSERT_ORDER.remove(observationKey); + } + + /** 尚无库存或运输占用的真实空实托首次使用也能建立共同串行行。 */ + @Test + void firstUseOfEmptyPhysicalPalletCreatesGuard() { + assertEquals(0, count()); + transaction.executeWithoutResult(status -> service.lock(List.of(901L))); + assertEquals(List.of(901L), ids()); + assertNotNull(createdAt(901)); + assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME='LL_PHYSICAL_PALLET_GUARD'", Integer.class)); + } + + /** 没有关联真实实托的计划传空集合时不生成替代主键。 */ + @Test + void emptyTargetSetDoesNotCreateSyntheticGuard() { + transaction.executeWithoutResult(status -> { + service.lock(List.of()); + service.lock(List.of()); + }); + assertEquals(0, count()); + assertTrue(INSERT_ORDER.get(observationKey).isEmpty()); + } + + /** 重复 upsert 和重复取锁保留原首次创建时间,不刷新租期或附加归属。 */ + @Test + void repeatedUsePreservesOriginalCreatedAt() { + LocalDateTime original = LocalDateTime.of(2020, 1, 2, 3, 4, 5); + jdbc.update("INSERT INTO ll_physical_pallet_guard(warehouse_pallet_id,created_at) VALUES(?,?)", 901, original); + transaction.executeWithoutResult(status -> { + service.lock(List.of(901L, 901L)); + service.lock(List.of(901L)); + }); + transaction.executeWithoutResult(status -> service.lock(List.of(901L))); + assertEquals(1, count()); + assertEquals(original, createdAt(901)); + } + + /** 触发器记录真实插入顺序,重复输入必须在 SQL 前去重并按数值升序执行。 */ + @Test + void databaseObservesUniqueAscendingCreationOrder() { + transaction.executeWithoutResult(status -> service.lock(List.of(303L, 101L, 202L, 101L, 303L))); + assertEquals(List.of(101L, 202L, 303L), List.copyOf(INSERT_ORDER.get(observationKey))); + assertEquals(List.of(101L, 202L, 303L), ids()); + } + + /** 一次校验完整目标,非法及空身份不应让前面的合法身份先产生 SQL。 */ + @Test + void invalidIdsAreRejectedBeforeAnyDatabaseInsert() { + assertThrows(IllegalArgumentException.class, + () -> transaction.executeWithoutResult(status -> service.lock(null))); + for (List invalid : List.of(List.of(901L, 0L), List.of(901L, -1L), Arrays.asList(901L, null))) { + assertThrows(IllegalArgumentException.class, + () -> transaction.executeWithoutResult(status -> service.lock(invalid))); + } + assertEquals(0, count()); + assertTrue(INSERT_ORDER.get(observationKey).isEmpty()); + } + + /** MANDATORY 对空集合也生效,不能在无外层事务时建立或短暂持有串行锁。 */ + @Test + void missingOuterTransactionIsRejected() { + assertThrows(IllegalTransactionStateException.class, () -> service.lock(List.of(901L))); + assertThrows(IllegalTransactionStateException.class, () -> service.lock(List.of())); + assertEquals(0, count()); + } + + /** 即使绕过 Spring 代理直接调用实现,也不能在事务外取得瞬时自动提交锁。 */ + @Test + void directInvocationCannotBypassTransactionRequirement() { + var direct = new PhysicalPalletSerialService(repository); + assertThrows(IllegalStateException.class, () -> direct.lock(List.of(901L))); + assertEquals(0, count()); + } + + /** 只读事务不能承担建行及持有业务串行锁的职责。 */ + @Test + void readOnlyCallerCannotAcquireGuard() { + var readOnly = new TransactionTemplate(transaction.getTransactionManager()); + readOnly.setReadOnly(true); + assertThrows(IllegalStateException.class, + () -> readOnly.executeWithoutResult(status -> service.lock(List.of(901L)))); + assertEquals(0, count()); + } + + /** 第二连接对同一真实实托必须等待第一外层事务提交后才能进入。 */ + @Test + @Timeout(30) + void samePhysicalPalletSerializesConcurrentTransactions() throws Exception { + jdbc.update("INSERT INTO ll_physical_pallet_guard(warehouse_pallet_id) VALUES(901)"); + var firstLocked = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var firstSession = new AtomicInteger(); + Future first = hold(List.of(901L), firstSession, firstLocked, releaseFirst); + try { + await(firstLocked); + var secondSession = new AtomicInteger(); + var secondSessionReady = new CountDownLatch(1); + Future second = workers.submit(() -> transaction.executeWithoutResult(status -> { + secondSession.set(sessionId()); + secondSessionReady.countDown(); + service.lock(List.of(901L)); + })); + await(secondSessionReady); + awaitGuardLockWait(secondSession.get(), firstSession.get(), second); + assertFalse(first.isDone()); + releaseFirst.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertEquals(1, count()); + } finally { + releaseFirst.countDown(); + } + } + + /** 不相交真实实托可以在另一事务仍持锁时完成,不形成全表业务互斥。 */ + @Test + @Timeout(30) + void disjointPhysicalPalletsProceedWhileAnotherTransactionHoldsItsGuard() throws Exception { + var firstLocked = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var firstSession = new AtomicInteger(); + Future first = hold(List.of(901L), firstSession, firstLocked, releaseFirst); + try { + await(firstLocked); + Future second = workers.submit(() -> transaction.executeWithoutResult(status -> { + assertNotEquals(firstSession.get(), sessionId()); + service.lock(List.of(902L)); + })); + second.get(5, TimeUnit.SECONDS); + assertFalse(first.isDone()); + assertEquals(1, releaseFirst.getCount()); + assertEquals(List.of(902L), ids()); + releaseFirst.countDown(); + first.get(5, TimeUnit.SECONDS); + assertEquals(List.of(901L, 902L), ids()); + } finally { + releaseFirst.countDown(); + } + } + + /** 新行随第一事务回滚并释放阻塞,等待者随后为同实托重新建行取锁。 */ + @Test + @Timeout(30) + void rollbackReleasesWaitingTransactionAndDoesNotKeepRolledBackCreationTime() throws Exception { + var firstLocked = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var firstSession = new AtomicInteger(); + LocalDateTime rolledBackTime = LocalDateTime.of(2010, 1, 2, 3, 4, 5); + Future first = workers.submit(() -> transaction.executeWithoutResult(status -> { + service.lock(List.of(903L)); + jdbc.update("UPDATE ll_physical_pallet_guard SET created_at=? WHERE warehouse_pallet_id=903", rolledBackTime); + firstSession.set(sessionId()); + firstLocked.countDown(); + await(releaseFirst); + status.setRollbackOnly(); + })); + try { + await(firstLocked); + var secondSession = new AtomicInteger(); + var secondSessionReady = new CountDownLatch(1); + Future second = workers.submit(() -> transaction.executeWithoutResult(status -> { + secondSession.set(sessionId()); + secondSessionReady.countDown(); + service.lock(List.of(903L)); + })); + await(secondSessionReady); + awaitGuardLockWait(secondSession.get(), firstSession.get(), second); + releaseFirst.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertEquals(List.of(903L), ids()); + assertNotEquals(rolledBackTime, createdAt(903)); + } finally { + releaseFirst.countDown(); + } + } + + /** 两个相反且重复的请求通过真实数据库完成,交集按同一数值顺序串行。 */ + @Test + @Timeout(30) + void overlappingReverseRequestsFinishWithOneGuardPerPhysicalPallet() throws Exception { + var start = new CountDownLatch(1); + Future first = workers.submit(() -> { + await(start); + transaction.executeWithoutResult(status -> service.lock(List.of(905L, 904L, 906L, 904L))); + }); + Future second = workers.submit(() -> { + await(start); + transaction.executeWithoutResult(status -> service.lock(List.of(906L, 905L, 904L, 906L))); + }); + start.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertEquals(List.of(904L, 905L, 906L), ids()); + } + + /** 第一调用方原事务持锁等待,不提交中间建行或借用独立事务。 */ + private Future hold(List ids, AtomicInteger session, CountDownLatch locked, CountDownLatch release) { + return workers.submit(() -> transaction.executeWithoutResult(status -> { + service.lock(ids); + session.set(sessionId()); + locked.countDown(); + await(release); + })); + } + + /** 在各调用方事务实际使用的连接上取得会话身份,不把线程启动标记视为数据库等待证据。 */ + private int sessionId() { + return jdbc.queryForObject("SELECT SESSION_ID()", Integer.class); + } + + /** 观察者连接须看到等待者执行串行表 SQL,且实际 BLOCKER_ID 正是持锁事务,才能释放持锁者。 */ + private void awaitGuardLockWait(int waiter, int holder, Future waiting) throws Exception { + assertTrue(waiter > 0 && holder > 0); + assertNotEquals(holder, waiter); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + List> lastObserved = List.of(); + while (System.nanoTime() < deadline) { + if (waiting.isDone()) { + waiting.get(); + fail("持锁事务尚未释放,但等待事务未进入数据库锁等待便已完成"); + } + lastObserved = jdbc.queryForList("SELECT SESSION_ID,EXECUTING_STATEMENT,SESSION_STATE,BLOCKER_ID " + + "FROM INFORMATION_SCHEMA.SESSIONS WHERE SESSION_ID=?", waiter); + if (lastObserved.size() != 1) continue; + var observed = lastObserved.get(0); + Object blocker = observed.get("BLOCKER_ID"); + String sql = String.valueOf(observed.get("EXECUTING_STATEMENT")).toUpperCase(Locale.ROOT); + if (blocker instanceof Number number && number.intValue() == holder + && sql.contains("LL_PHYSICAL_PALLET_GUARD") && (sql.contains("INSERT") || sql.contains("UPDATE"))) { + assertFalse(waiting.isDone()); + return; + } + } + fail("未观察到真实实托数据库锁等待,waiter=" + waiter + ",holder=" + holder + ",sessions=" + lastObserved); + } + + /** 有界协调并发测试,失败或中断时及时退出以释放事务锁。 */ + private void await(CountDownLatch latch) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("等待并发测试事务超时"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("并发测试事务已中断", interrupted); + } + } + + /** 普通连接只读取已提交的真实实托主键。 */ + private List ids() { + return jdbc.queryForList("SELECT warehouse_pallet_id FROM ll_physical_pallet_guard ORDER BY warehouse_pallet_id", Long.class); + } + + /** 核对共同串行行数量,不访问其他业务表或环境。 */ + private int count() { + return jdbc.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard", Integer.class); + } + + /** 读取数据库实际保留的首次创建时间。 */ + private LocalDateTime createdAt(long id) { + return jdbc.queryForObject("SELECT created_at FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=?", LocalDateTime.class, id); + } + + /** H2 调用的真实插入观测器,仅用于证明去重与数据库执行顺序。 */ + public static class GuardInsertOrderTrigger implements Trigger { + private String observationKey; + + /** 允许 H2 反射创建本测试的数据库触发器。 */ + public GuardInsertOrderTrigger() { } + + /** 将触发器记录隔离到其真实测试数据库。 */ + @Override + public void init(Connection connection, String schema, String name, String table, boolean before, int type) { + observationKey = name; + } + + /** 记录数据库实际执行的插入尝试,不记录 Java Mapper 的调用顺序。 */ + @Override + public void fire(Connection connection, Object[] oldRow, Object[] newRow) { + INSERT_ORDER.get(observationKey).add(((Number) newRow[0]).longValue()); + } + + /** 观测器不持有数据库外资源。 */ + @Override + public void close() { } + + /** 用例结束统一清理观测记录,不在删除触发器时改写业务表。 */ + @Override + public void remove() { } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java index e831aa9de..98084f18c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java @@ -13,11 +13,28 @@ import static org.assertj.core.api.Assertions.*; class CustomerOrderProgressMapperTest { private JdbcTemplate jdbc; private CustomerOrderProgressMapper mapper; + /** 两客户共用一单一任务,计划和在途均按各自真实明细归属统计。 */ + @Test void sharedDispatchAndTaskKeepBothCustomerProgressSeparate() { + jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(101,10,'22',501,'W1','FBA2')"); + jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(501,1002,30)"); + assertThat(mapper.sources("11",null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L); + assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.scheduledPackages()).isEqualTo(30)); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,10)"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700),(601,'22',101,401,7,700)"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4),(901,601,'22',3,7)"); + jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','OUTBOUNDED'),(10002,'22','OUTBOUNDED')"); + jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1'),(10003,'22',10,10002,'DELIVERING','CODE:FBA2')"); + jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP"); + assertThat(mapper.sources("11",null)).extracting(s->s.deliveringPackages()).containsExactly(4L,0L); + assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.deliveringPackages()).isEqualTo(7)); + assertThat(mapper.sources("UNRELATED",null)).isEmpty(); + } + /** 使用无客户头的真实新模型,按明细验证查询归属。 */ @BeforeEach void setup() throws Exception { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:tail-progress-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); jdbc=new JdbcTemplate(ds); jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_order_id VARCHAR,source_inbound_id VARCHAR,source_region VARCHAR,order_type VARCHAR,package_count INT)"); - jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,status VARCHAR,depart_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR,stage VARCHAR,departed_at TIMESTAMP)"); jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,overseas_warehouse_id VARCHAR,destination_type VARCHAR,delivery_address VARCHAR,postal_code VARCHAR,destination_warehouse_code VARCHAR,fba_warehouse_code VARCHAR,destination_warehouse_id VARCHAR)"); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,planned_package_count INT)"); jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); @@ -25,11 +42,11 @@ class CustomerOrderProgressMapperTest { jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR,dispatch_item_id BIGINT,inventory_position_id BIGINT,packages INT,task_id BIGINT)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,pick_id BIGINT,customer_id VARCHAR,order_id BIGINT,packages INT)"); - jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)"); jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT,outbound_order_id BIGINT,status VARCHAR,delivery_group_key VARCHAR)"); jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR,status VARCHAR)"); jdbc.update("INSERT INTO ll_order VALUES(1,'11','101','201','FIRSTLEG','FBA',10),(2,'11','102','202','FIRSTLEG','FBA',20),(3,'22','103','203','FIRSTLEG','FBA',30)"); - jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'11','PENDING_DEPARTURE',NULL)"); + jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'ACTIVE','BEFORE_LOADING',NULL)"); jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(100,10,'11',500,'W1','FBA1')"); jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(1000,'11',1),(1001,'11',2),(1002,'22',3)"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(500,1000,10),(500,1001,20),(500,1002,30)"); @@ -37,6 +54,7 @@ class CustomerOrderProgressMapperTest { var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class); } + /** 计划托盘上的所有本客户来源均计入,取消后不再计入。 */ @Test void plannedSharedPalletIncludesEveryOwnSourceAndExcludesCancellation() { var sources=mapper.sources("11", null);assertThat(sources).hasSize(2); assertThat(sources.get(0).scheduledPackages()).isEqualTo(10); @@ -47,6 +65,7 @@ class CustomerOrderProgressMapperTest { assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0); assertThat(mapper.sources("22", null)).hasSize(1).allMatch(s->s.scheduledPackages()==0); } + /** 已收货使用实际库存和扫码,不与计划量重复累计。 */ @Test void receivedPalletUsesInventoryAndActualPicksWithoutCountingBothPlanAndStock() { jdbc.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=800"); jdbc.update("INSERT INTO ll_inventory_balance VALUES(300,'11',1),(301,'11',2)"); @@ -56,14 +75,15 @@ class CustomerOrderProgressMapperTest { assertThat(sources.get(1).scheduledPackages()).isEqualTo(20); assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0)); } + /** 实际发车、出库及目的地须同时成立才计入在途。 */ @Test void deliveryRequiresActualDepartureOutboundAndMatchingDestinationAttribution() { - jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,'11',10)"); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,10)"); jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700)"); jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4)"); jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','WAIT_OUTBOUND')"); jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1')"); assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0); - jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',depart_at=CURRENT_TIMESTAMP"); + jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP"); assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0); jdbc.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'"); assertThat(mapper.sources("11", null).get(0).deliveringPackages()).isEqualTo(4); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/SharedDispatchReturnQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/SharedDispatchReturnQueryTest.java new file mode 100644 index 000000000..dc541a77f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/SharedDispatchReturnQueryTest.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.mapper; + +import com.baiyue.lastleg.exceptionorder.mapper.ReturnCartonStockReceiptMapper; +import com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper; +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import static org.assertj.core.api.Assertions.assertThat; + +/** 无客户头的一单一任务执行生产查询,验证退回证明仍逐客户隔离。 */ +class SharedDispatchReturnQueryTest { + private JdbcTemplate db; + private NamedParameterJdbcTemplate named; + + /** 两个客户共用唯一排车和PDA任务,实物、派送及逐票归属分别保存。 */ + @BeforeEach void setup() { + var source=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + db=new JdbcTemplate(source);named=new NamedParameterJdbcTemplate(source); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(30),status VARCHAR(20))"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,return_receipt_id BIGINT,package_count INT,warehouse_pallet_id BIGINT,inbound_code VARCHAR(30),source_stage VARCHAR(30),destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); + db.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,outbound_order_id BIGINT,delivery_group_key VARCHAR(100),delivery_no VARCHAR(30),status VARCHAR(30))"); + db.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,outbound_no VARCHAR(30),status VARCHAR(30))"); + db.execute("CREATE TABLE ll_outbound_pallet_item(id BIGINT,customer_id VARCHAR(20),outbound_order_id BIGINT,warehouse_pallet_id BIGINT,inbound_code VARCHAR(30),destination_warehouse_code VARCHAR(30))"); + db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,outbound_no VARCHAR(30),last_mile_order_id BIGINT,status VARCHAR(30),return_direction VARCHAR(30))"); + db.execute("CREATE TABLE ll_exception_return_item(id BIGINT,customer_id VARCHAR(20),exception_order_id BIGINT,outbound_pallet_item_id BIGINT,return_carton_id BIGINT,inventory_position_id BIGINT,approved_return_packages BIGINT,received_packages BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT,return_carton_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,return_receipt_id BIGINT)"); + db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),receipt_no VARCHAR(30),received_packages BIGINT,received_at TIMESTAMP,exception_order_id BIGINT)"); + db.update("INSERT INTO ll_dispatch_order VALUES(1,'DP-1','ACTIVE')"); + db.update("INSERT INTO ll_dispatch_pda_task VALUES(201,1)"); + db.update("INSERT INTO ll_dispatch_item VALUES(10,'C',1,91,1,101,'RL-1','RETURN_RELABEL','FBA',NULL,NULL,'ONT8',NULL,NULL),(11,'OTHER',1,92,1,102,'RL-2','RETURN_RELABEL','FBA',NULL,NULL,'ONT8',NULL,NULL)"); + db.update("INSERT INTO ll_outbound_order VALUES(31,'C',1,'OUT-1','OUTBOUNDED'),(32,'OTHER',1,'OUT-2','OUTBOUNDED')"); + db.update("INSERT INTO ll_outbound_pallet_item VALUES(51,'C',31,101,'RL-1','ONT8'),(52,'OTHER',32,102,'RL-2','ONT8')"); + db.update("INSERT INTO ll_delivery_order VALUES(21,'C',1,31,'CODE:ONT8','DL-1','DELIVERING'),(22,'OTHER',1,32,'CODE:ONT8','DL-2','DELIVERING')"); + db.update("INSERT INTO ll_exception_order VALUES(1,'C',21,'OUT-1',101,'PROCESSING','OVERSEAS_WAREHOUSE'),(2,'OTHER',22,'OUT-2',102,'PROCESSING','OVERSEAS_WAREHOUSE')"); + db.update("INSERT INTO ll_exception_return_item VALUES(41,'C',1,51,701,NULL,1,0),(42,'OTHER',2,52,702,NULL,1,0)"); + db.update("INSERT INTO ll_dispatch_pda_pick VALUES(501,'C',201,10,701,NULL,1,8),(502,'OTHER',201,11,702,NULL,1,9)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(601,'C',501,101,1,91),(602,'OTHER',502,102,1,92)"); + db.update("INSERT INTO ll_return_stock_receipt VALUES(91,'C','RSR-1',1,CURRENT_TIMESTAMP,1),(92,'OTHER','RSR-2',1,CURRENT_TIMESTAMP,2)"); + } + + /** 同车同任务的本客户原箱证明成立,其他客户不能借用其异常或箱。 */ + @Test void cartonShipmentFollowsRealCustomerItemPickAndOrder() { + var sql=sql(ReturnCartonStockReceiptMapper.class,"shipment"); + assertThat(named.queryForList(sql,Map.of("customerId","C","exceptionId",1L,"itemId",51L,"cartonId",701L))) + .singleElement().satisfies(row->assertThat(((Number)row.get("dispatch_item_id")).longValue()).isEqualTo(10)); + assertThat(named.queryForList(sql,Map.of("customerId","OTHER","exceptionId",2L,"itemId",52L,"cartonId",702L))) + .singleElement().satisfies(row->assertThat(((Number)row.get("dispatch_item_id")).longValue()).isEqualTo(11)); + assertThat(named.queryForList(sql,Map.of("customerId","OTHER","exceptionId",1L,"itemId",51L,"cartonId",701L))).isEmpty(); + assertThat(named.queryForList(sql,Map.of("customerId","C","exceptionId",1L,"itemId",52L,"cartonId",702L))).isEmpty(); + } + + /** 相同卸货组也只能关联本客户派送,错误客户归属不能增加他人的扫码量。 */ + @Test void redispatchHistoryKeepsBothMembersAndTheirDeliverySeparate() { + db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(603,'C',501,101,99,92)"); + var sql=sql(ReturnStockReceiptMapper.class,"history"); + var own=named.queryForList(sql,Map.of("customerId","C","exceptionId",1L)); + var sibling=named.queryForList(sql,Map.of("customerId","OTHER","exceptionId",2L)); + assertThat(own).singleElement().satisfies(row->{ + assertThat(row.get("dispatchNo")).isEqualTo("DP-1");assertThat(row.get("deliveryNo")).isEqualTo("DL-1"); + assertThat(((Number)row.get("pickedPackages")).longValue()).isEqualTo(1); + }); + assertThat(sibling).singleElement().satisfies(row->{ + assertThat(row.get("dispatchNo")).isEqualTo("DP-1");assertThat(row.get("deliveryNo")).isEqualTo("DL-2"); + assertThat(((Number)row.get("pickedPackages")).longValue()).isEqualTo(1); + }); + assertThat(named.queryForList(sql,Map.of("customerId","OTHER","exceptionId",1L))).isEmpty(); + assertThat(named.queryForList(sql,Map.of("customerId","c","exceptionId",1L))).isEmpty(); + assertThat(named.queryForList(sql,Map.of("customerId","C ","exceptionId",1L))).isEmpty(); + } + + /** H2用等价可变长二进制转换执行MySQL的BINARY比较,保留大小写和尾空格隔离。 */ + private String sql(Class type,String name) { + var method=Arrays.stream(type.getMethods()).filter(value->value.getName().equals(name)).findFirst().orElseThrow(); + var query=String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}",":$1"); + // 仅替换方言表达式,客户谓词、关联及状态条件均直接来自生产SQL;MySQL执行仍待环境验收。 + return query.replaceAll("(?s)\\bBINARY\\s+(CASE\\b.*?\\bEND)","CAST($1 AS VARBINARY)") + .replaceAll("\\bBINARY\\s+([A-Za-z_][A-Za-z0-9_.]*|:\\w+)","CAST($1 AS VARBINARY)"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java new file mode 100644 index 000000000..2e50caf6a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java @@ -0,0 +1,539 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; +import java.time.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 实际MyBatis、H2和Spring事务证明仓内调用端与运输使用同一组成冻结边界。 */ +class PdaPalletDispatchFreezePersistenceTest { + private static final LocalDateTime AT = LocalDateTime.of(2026,10,19,12,0); + private static final PdaWarehouseIdentity ACTOR = new PdaWarehouseIdentity("7","picker",88L); + + /** 已排申请A与待组托申请B同目的仓,也不能向A的实托追加。 */ + @Test void dispatchedTargetApplicationBlocksAnotherCustomerBeforePhysicalWrite() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.application(1,"RL-A","100",2001,"PRE_DISPATCH_CREATED"); + f.position(101,"100",501,40,3001,1); f.item(11,1,"100",101L,null,"A-BOX"); + assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","freeze-target")); + assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + + /** 普通入仓/退回目标没有换标申请,同样受正式和有效草稿运输占用保护。 */ + @Test void ordinaryAndReturnedStockClaimsBlockWithoutBorrowingARelabelApplication() throws Exception { + for (boolean returned : List.of(false,true)) { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.position(101,"100",501,40,3001,0); + if (returned) f.db.update("INSERT INTO ll_return_stock_receipt VALUES(1,'100',3001,101)"); + else f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1,'100',3001,40,101)"); + f.db.update("INSERT INTO ll_dispatch_physical_pallet_lock VALUES(1,40,40,'ACTIVE',99,NULL)"); + assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","ordinary-"+returned)); + assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + } + + /** 统一UTC时钟只忽略已到期草稿;独立逻辑claim也不能漏检。 */ + @Test void draftExpiryUsesBusinessClockAndLogicalClaimsAreChecked() throws Exception { + for (boolean active : List.of(true,false)) { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.db.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,40,'ACTIVE',NULL,?)",active?AT.plusNanos(1_000_000):AT); + if (active) assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","draft-active")); + else { assertEquals("COMPLETED",f.bind("RL-B","draft-expired").status()); assertEquals("TARGET",f.cartonPallet(802)); } + } + } + + /** 原生退货完整执行绑定;原始退货订单即使暂扣仍只锁身份,不新增仓内资格。 */ + @Test void unboundNativeReturnLocksBothActualOrdersAndCompletesBinding() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.db.update("UPDATE ll_order SET detained=1 WHERE id=3002"); + assertEquals("COMPLETED",f.bind("RL-B","native-return").status()); + assertEquals("TARGET",f.cartonPallet(802)); + assertEquals(List.of(2002L,3002L),f.orderLocks.get(Thread.currentThread().getName())); + assertEquals(1L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=40",Long.class)); + } + + /** 合箱后的每张原始退货单均取真实订单锁,不能只锁sourceReceiptId代表的一票。 */ + @Test void mergedNativeCartonKeepsEveryOriginalReturnOrder() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.db.update("INSERT INTO ll_order(id,customer_id) VALUES(3003,'200')"); + f.db.update("INSERT INTO ll_return_order VALUES(3,'200','RT-3',3003)"); + f.db.update("UPDATE ll_return_inventory_carton SET source_return_no='RT-2,RT-3' WHERE id=802"); + assertEquals("COMPLETED",f.bind("RL-B","merged-native").status()); + assertEquals(List.of(2002L,3002L,3003L),f.orderLocks.get(Thread.currentThread().getName())); + } + + /** 已绑定计划按真实物理ID取源,排除同码其他实托、异仓及尚未绑定的计划订单。 */ + @Test void plannedOrdersUsePhysicalBindingAndParentWarehouse() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.planned(101,"100",88,5001,40L,"TARGET"); + f.planned(102,"300",88,5002,41L,"TARGET"); + f.planned(103,"400",99,5003,40L,"TARGET"); + f.planned(104,"500",88,5004,null,"TARGET"); + assertEquals("COMPLETED",f.bind("RL-B","planned-warehouse").status()); + assertEquals(List.of(2002L,3002L,5001L),f.orderLocks.get(Thread.currentThread().getName())); + } + + /** 展示码不同也不能漏掉实际绑定本实托的真实来源订单,身份以最终物理绑定列为准。 */ + @Test void actualPlanBindingIsNotLostWhenPalletCodeDiffers() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.planned(101,"100",88,5001,40L,"ANOTHER-CODE"); + assertEquals("COMPLETED",f.bind("RL-B","planned-physical-id").status()); + assertEquals(List.of(2002L,3002L,5001L),f.orderLocks.get(Thread.currentThread().getName())); + } + + /** 原始订单身份缺失时明确拒绝,不能仅借申请自身的履约订单通过门禁。 */ + @Test void missingNativeOriginalOrderCannotBeSilentlyOmitted() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.db.update("DELETE FROM ll_return_order WHERE id=2"); + assertTrue(assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","missing-original")).getReason().contains("原始订单身份")); + assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + + /** 两个不相交申请都预读空托,第二个等待共同锁后必须发现新成员并整笔拒绝。 */ + @Test void concurrentEmptyTargetCannotAcceptAStaleComposition() throws Exception { + var f = new Fixture(); f.nativeSource(1,"RL-A","100",801,3001); f.nativeSource(2,"RL-B","200",802,3002); + var firstInside = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1); var secondAtSerial = new CountDownLatch(1); + f.serialObserver = ids -> { if (Thread.currentThread().getName().equals("second")) secondAtSerial.countDown(); }; + doAnswer(call -> { if ("empty-B".equals(call.getArgument(4))) { firstInside.countDown(); assertTrue(releaseFirst.await(5,TimeUnit.SECONDS)); } return f.transition(call.getArgument(4)); }) + .when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> { Thread.currentThread().setName("first"); return f.bind("RL-B","empty-B"); }); + assertTrue(firstInside.await(5,TimeUnit.SECONDS)); + var second = pool.submit(() -> { Thread.currentThread().setName("second"); return assertThrows(ResponseStatusException.class,() -> f.bind("RL-A","empty-A")); }); + assertTrue(secondAtSerial.await(5,TimeUnit.SECONDS)); assertFalse(second.isDone()); releaseFirst.countDown(); + assertEquals("COMPLETED",first.get(5,TimeUnit.SECONDS).status()); assertTrue(second.get(5,TimeUnit.SECONDS).getReason().contains("组成已变化")); + assertEquals("TARGET",f.cartonPallet(802)); assertEquals("UNBOUND",f.cartonPallet(801)); + } finally { releaseFirst.countDown(); pool.shutdownNow(); } + } + + /** 同一闭包从B或A进入均实际按A、B取锁,不出现B→A反序。 */ + @Test void oppositeSourceApplicationsTakeTheSameRealLockOrder() throws Exception { + var f = new Fixture(); f.application(1,"RL-A","100",2001,"WAIT_PRE_DISPATCH"); f.application(2,"RL-B","200",2002,"WAIT_PRE_DISPATCH"); + f.position(101,"100",501,40,3001,1); f.position(102,"200",502,40,3002,1); + f.item(11,1,"100",101L,null,"A-BOX"); f.item(12,2,"200",102L,null,"B-BOX"); + var start = new CountDownLatch(1); var pool = Executors.newFixedThreadPool(2); + try { + var a = pool.submit(() -> { Thread.currentThread().setName("source-A"); start.await(); f.guard("RL-A"); return true; }); + var b = pool.submit(() -> { Thread.currentThread().setName("source-B"); start.await(); f.guard("RL-B"); return true; }); + start.countDown(); assertTrue(a.get(5,TimeUnit.SECONDS)); assertTrue(b.get(5,TimeUnit.SECONDS)); + assertEquals(List.of("RL-A","RL-B"),f.applicationLocks.get("source-A")); + assertEquals(List.of("RL-A","RL-B"),f.applicationLocks.get("source-B")); + } finally { pool.shutdownNow(); } + } + + /** 箱记录已真实更新后注入失败,业务回滚且远端补偿发生在共同锁仍持有的事务内。 */ + @Test void laterFailureRollsBackNativeBindingAndCompensatesBeforeUnlock() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); var inside = new AtomicBoolean(); + doAnswer(call -> { assertEquals("TARGET",f.cartonPallet(802)); throw new IllegalStateException("绑定历史写入失败"); }) + .when(f.histories).insert(any(PdaRelabelHistory.class)); + doAnswer(call -> { inside.set(TransactionSynchronizationManager.isActualTransactionActive()); assertEquals(1L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=40",Long.class)); return null; }) + .when(f.physical).compensate(eq(88L),any()); + assertThrows(IllegalStateException.class,() -> f.bind("RL-B","rollback")); + assertTrue(inside.get()); assertEquals("UNBOUND",f.cartonPallet(802)); + assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_relabel_pda_history",Long.class)); + assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard",Long.class)); + assertEquals("FAILED_COMPENSATED",f.operationStatus("rollback")); + assertEquals("APPLIED",f.attempts("rollback").get(0).path("receipt").path("operationStatus").asText()); + verify(f.physical).compensate(eq(88L),any()); + } + + /** 普通AVAILABLE库存也不能通过assemble追加到已排车的其他客户实托。 */ + @Test void genericAvailableSourceCannotEnterDispatchedTarget() throws Exception { + for (boolean relabelTarget : List.of(true,false)) { + var f = new Fixture(); + if (relabelTarget) f.application(1,"RL-A","100",2001,"PRE_DISPATCH_CREATED"); + f.position(101,"100",501,40,3001,relabelTarget?1:0); + if (relabelTarget) f.item(11,1,"100",101L,null,"A-BOX"); + f.position(102,"200",502,30,3002,0); + f.db.update("INSERT INTO ll_dispatch_physical_pallet_lock VALUES(1,40,40,'ACTIVE',99,NULL)"); + assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"generic-target")); + assertEquals(1L,f.available(102)); + assertEquals(relabelTarget?1L:0L,f.db.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)); + assertEquals(relabelTarget?0L:1L,f.available(101)); + verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + } + + /** 来源实托已占用时普通移动同样被挡住,空申请闭包不代表可以越过运输claim。 */ + @Test void genericClaimedSourceCannotMoveWithAnEmptyApplicationClosure() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); + f.db.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,30,'ACTIVE',99,NULL)"); + assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"generic-source")); + assertEquals(1L,f.available(102)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + + /** 没有关联申请、没有运输claim的普通组托继续执行真实库存和流水,不增加暂扣资格。 */ + @Test void genericEmptyApplicationClosureCompletesOrdinaryAssemble() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); + f.db.update("UPDATE ll_order SET detained=1 WHERE id=3002"); + assertEquals("COMPLETED",f.assemble("SOURCE",102,"generic-success").status()); + assertEquals(0L,f.available(102)); assertEquals(1L,f.targetAvailable()); + assertEquals(2L,f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)); + assertFalse(f.applicationLocks.containsKey(Thread.currentThread().getName())); + assertEquals(List.of(3002L),f.orderLocks.get(Thread.currentThread().getName())); + } + + /** 两个空申请闭包竞争空目标,第二个在共同锁后看到新普通位置和订单必须重试。 */ + @Test void concurrentGenericEmptyTargetRejectsNewOrdinaryMember() throws Exception { + var f = new Fixture(); f.position(101,"100",501,30,3001,0); f.position(102,"200",502,50,3002,0); + var firstInside = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1); var secondAtSerial = new CountDownLatch(1); + f.serialObserver = ids -> { if (Thread.currentThread().getName().equals("generic-second")) secondAtSerial.countDown(); }; + doAnswer(call -> { + if (Thread.currentThread().getName().equals("generic-first") && firstInside.getCount()>0) { firstInside.countDown(); assertTrue(releaseFirst.await(5,TimeUnit.SECONDS)); } + return f.transition(call.getArgument(1),call.getArgument(2),call.getArgument(4)); + }).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> { Thread.currentThread().setName("generic-first"); return f.assemble("SOURCE",101,"generic-empty-A"); }); + assertTrue(firstInside.await(5,TimeUnit.SECONDS)); + var second = pool.submit(() -> { Thread.currentThread().setName("generic-second"); return assertThrows(ResponseStatusException.class,() -> f.assemble("OTHER",102,"generic-empty-B")); }); + assertTrue(secondAtSerial.await(5,TimeUnit.SECONDS)); assertFalse(second.isDone()); releaseFirst.countDown(); + assertEquals("COMPLETED",first.get(5,TimeUnit.SECONDS).status()); assertTrue(second.get(5,TimeUnit.SECONDS).getReason().contains("组成已变化")); + assertEquals(0L,f.available(101)); assertEquals(1L,f.available(102)); assertEquals(1L,f.targetAvailable()); + verify(f.physical,times(2)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } finally { releaseFirst.countDown(); pool.shutdownNow(); } + } + + /** 普通库存已真实移入目标后失败,两次远端补偿都在共同锁事务内,库存和流水随后回滚。 */ + @Test void genericFailureCompensatesBeforeUnlockAndRollsBackInventory() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); var inside = new AtomicBoolean(); + doAnswer(call -> { assertEquals(0L,f.available(102)); assertEquals(1L,f.targetAvailable()); throw new IllegalStateException("普通组托历史失败"); }) + .when(f.operationHistory).insert(any(PdaOperationHistory.class)); + doAnswer(call -> { inside.set(TransactionSynchronizationManager.isActualTransactionActive()); assertEquals(2L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard",Long.class)); return null; }) + .when(f.physical).compensate(eq(88L),any()); + assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"generic-rollback")); + assertTrue(inside.get()); assertEquals(1L,f.available(102)); assertEquals(0L,f.targetAvailable()); + assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)); + assertEquals("FAILED_COMPENSATED",f.operationStatus("generic-rollback")); + assertEquals(2,f.attempts("generic-rollback").size()); + verify(f.physical,times(2)).compensate(eq(88L),any()); + } + + /** 首次POST已产生远端效果但响应丢失时,没有已知回执绝不等于全部已补偿。 */ + @Test void firstApplyCommittedWithoutReceiptRemainsUnresolvedAndOriginalKeyCannotReplay() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); var effects = new ConcurrentHashMap(); + doAnswer(call -> { PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); effects.put(pallet.palletId(),call.getArgument(2)); throw new IllegalStateException("远端已提交但响应丢失"); }) + .when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"unknown-first")); + assertEquals("IDLE",effects.get(30L)); assertEquals("COMPENSATION_REQUIRED",f.operationStatus("unknown-first")); + var attempts=f.attempts("unknown-first"); assertEquals(1,attempts.size()); assertTrue(attempts.get(0).path("receipt").isNull()); + assertEquals(30L,attempts.get(0).path("palletId").asLong()); assertFalse(attempts.get(0).path("operationKey").asText().isBlank()); + assertEquals(1L,f.available(102)); assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"unknown-first")); + verify(f.physical,times(1)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,never()).compensate(anyLong(),any()); + } + + /** 已知第一项补偿成功,不能抹去第二次已提交但未回执的实托调用。 */ + @Test void laterUnknownApplyCannotBeClassifiedCompensatedByRestoringEarlierReceipt() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); var effects = new ConcurrentHashMap(); var calls = new AtomicInteger(); + doAnswer(call -> { + PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); String status=call.getArgument(2); effects.put(pallet.palletId(),status); + if (calls.incrementAndGet()==2) throw new IllegalStateException("第二个实托已提交但回执解析失败"); + return f.transition(pallet,status,call.getArgument(4)); + }).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + doAnswer(call -> { assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); PalletPhysicalGateway.Transition receipt=call.getArgument(1); effects.put(receipt.palletId(),receipt.beforeStatus()); return null; }) + .when(f.physical).compensate(anyLong(),any()); + assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"unknown-later")); + assertEquals("OCCUPIED",effects.get(30L)); assertEquals("OCCUPIED",effects.get(40L)); + assertEquals("COMPENSATION_REQUIRED",f.operationStatus("unknown-later")); + var attempts=f.attempts("unknown-later"); assertEquals(2,attempts.size()); + assertEquals("APPLIED",attempts.get(0).path("receipt").path("operationStatus").asText()); assertTrue(attempts.get(1).path("receipt").isNull()); + assertEquals(40L,attempts.get(1).path("palletId").asLong()); assertEquals(1L,f.available(102)); + assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"unknown-later")); + verify(f.physical,times(2)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,times(1)).compensate(eq(88L),any()); + } + + /** 原生无来源托盘分支同样持久保留已发未确认事实,原箱指针回滚且原键不再发写。 */ + @Test void nativeReturnUnknownApplyPersistsPendingAttemptAndBlocksOriginalKey() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); var committed = new AtomicBoolean(); + doAnswer(call -> { committed.set(true); throw new IllegalStateException("退货实托已提交但响应丢失"); }) + .when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,() -> f.bind("RL-B","native-unknown")); + assertTrue(committed.get()); assertEquals("UNBOUND",f.cartonPallet(802)); assertEquals("COMPENSATION_REQUIRED",f.operationStatus("native-unknown")); + var attempt=f.attempts("native-unknown").get(0); assertEquals("native-unknown",attempt.path("operationKey").asText()); assertTrue(attempt.path("receipt").isNull()); + assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","native-unknown")); + verify(f.physical,times(1)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,never()).compensate(anyLong(),any()); + } + + /** 空、身份不符以及已补偿的旧回执都不能冒充本次有效APPLIED成功。 */ + @Test void invalidOrCompensatedReceiptNeverAdvancesAndRemainsPending() throws Exception { + for (String kind : List.of("NULL","OTHER_KEY","COMPENSATED")) { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); + doAnswer(call -> { + if (kind.equals("NULL")) return null; + PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); + return new PalletPhysicalGateway.Transition(kind.equals("OTHER_KEY")?"different-key":call.getArgument(4),pallet.palletId(),pallet.palletCode(),88, + pallet.status(),call.getArgument(2),pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,kind.equals("COMPENSATED")?"COMPENSATED":"APPLIED",true); + }).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"invalid-"+kind)); + assertEquals("COMPENSATION_REQUIRED",f.operationStatus("invalid-"+kind)); assertTrue(f.attempts("invalid-"+kind).get(0).path("receipt").isNull()); + assertEquals(1L,f.available(102)); assertEquals(0L,f.targetAvailable()); verify(f.physical,never()).compensate(anyLong(),any()); + } + } + + /** Warehouse的APPLIED精确重放仍是有效回执,不能凭replayed标记拒绝合法成功。 */ + @Test void matchingAppliedReplayReceiptRemainsValid() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); + doAnswer(call -> { + PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); + return new PalletPhysicalGateway.Transition(call.getArgument(4),pallet.palletId(),pallet.palletCode(),88,pallet.status(),call.getArgument(2), + pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,"APPLIED",true); + }).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + assertEquals("COMPLETED",f.assemble("SOURCE",102,"applied-replay").status()); + assertEquals("COMPLETED",f.operationStatus("applied-replay")); assertEquals(1L,f.targetAvailable()); + assertTrue(f.attempts("applied-replay").get(0).path("receipt").path("replayed").asBoolean()); + } + + /** 隔离数据库保留实际Mapper、事务和共享串行提供者,仅远端资源使用受控替身。 */ + static class Fixture { + final JdbcTemplate db; + final PdaPalletOperationService service = new PdaPalletOperationService(); + final PalletPhysicalGateway physical = mock(PalletPhysicalGateway.class); + final PdaRelabelHistoryMapper histories; + final PdaWarehouseMapper operationHistory; + final RelabelApplicationMapper applications; + final TransactionTemplate tx; + final String baseline; + final Map> applicationLocks = new ConcurrentHashMap<>(); + final Map> orderLocks = new ConcurrentHashMap<>(); + volatile Consumer> serialObserver = ids -> { }; + + /** 构建实际数据映射与Spring事务;测试表只服务本次隔离用例。 */ + Fixture() throws Exception { + // 底层连接故意默认RR;完整作业入口必须显式切到RC,不能借H2默认RC通过。 + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","") { + /** 每条新连接模拟目标MySQL默认RR。 */ + @Override public java.sql.Connection getConnection() throws java.sql.SQLException { + var connection = super.getConnection(); connection.setTransactionIsolation(java.sql.Connection.TRANSACTION_REPEATABLE_READ); return connection; + } + }; + db = new JdbcTemplate(ds); + baseline = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for (var type : List.of(RelabelApplication.class,RelabelItem.class,ReturnInventoryCarton.class,InventoryPosition.class,InventoryBalance.class,InventoryLedger.class,PdaPalletOperation.class,PdaRelabelTask.class,PdaRelabelHistory.class,PdaOperationHistory.class)) createEntity(type); + createColumns("ll_order",List.of("id","customer_id","detained")); + createColumns("ll_inbound_stock_receipt",List.of("id","customer_id","order_id","warehouse_pallet_id","inventory_position_id")); + createColumns("ll_return_stock_receipt",List.of("id","customer_id","order_id","inventory_position_id")); + createColumns("ll_inbound_plan",List.of("id","customer_id","overseas_warehouse_id")); + createColumns("ll_inbound_plan_pallet",List.of("id","customer_id","plan_id","pallet_no","warehouse_pallet_id")); + createColumns("ll_inbound_plan_pallet_allocation",List.of("pallet_id","line_id","customer_id","plan_id")); + createColumns("ll_inbound_plan_line",List.of("id","customer_id","order_id","plan_id")); + createColumns("ll_return_order",List.of("id","customer_id","return_no","lastleg_order_id")); + createColumns("ll_dispatch_physical_pallet_lock",List.of("id","warehouse_pallet_id","active_warehouse_pallet_id","status","dispatch_order_id","expires_at")); + createColumns("ll_dispatch_pallet_lock",List.of("id","warehouse_pallet_id","status","dispatch_order_id","expires_at")); + createColumns("ll_physical_pallet_guard",List.of("warehouse_pallet_id","created_at")); + createColumns("ll_warehouse_transfer_inventory_allocation",List.of("customer_id","inventory_position_id","transfer_order_id","package_count","status")); + var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + for (var type : List.of(PdaPalletOperationMapper.class,PhysicalPalletSerialMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,ReturnInventoryCartonMapper.class,PdaRelabelTaskMapper.class,PdaRelabelHistoryMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryCommandMapper.class,PdaWarehouseMapper.class)) configuration.addMapper(type); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); + var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); + PdaPalletOperationMapper actual = sessions.getMapper(PdaPalletOperationMapper.class); + PdaPalletOperationMapper traced = (PdaPalletOperationMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{PdaPalletOperationMapper.class},(proxy,method,args) -> { + if (method.getName().equals("lockMutationApplication")) applicationLocks.computeIfAbsent(Thread.currentThread().getName(),key -> new CopyOnWriteArrayList<>()).add((String)args[0]); + if (method.getName().equals("lockMutationOrder")) orderLocks.computeIfAbsent(Thread.currentThread().getName(),key -> new CopyOnWriteArrayList<>()).add((Long)args[1]); + try { return method.invoke(actual,args); } catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); } + }); + var manager = new DataSourceTransactionManager(ds); tx = new TransactionTemplate(manager); tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + var serial = new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(sessions.getMapper(PhysicalPalletSerialMapper.class))); + PhysicalPalletSerialPort observedSerial = ids -> { + try { assertEquals(java.sql.Connection.TRANSACTION_READ_COMMITTED,DataSourceUtils.getConnection(ds).getTransactionIsolation()); } + catch (java.sql.SQLException failure) { throw new IllegalStateException(failure); } + serialObserver.accept(ids); serial.lock(ids); + }; + applications = sessions.getMapper(RelabelApplicationMapper.class); histories = spy(sessions.getMapper(PdaRelabelHistoryMapper.class)); + operationHistory = spy(sessions.getMapper(PdaWarehouseMapper.class)); + var historyService = new PdaWarehouseService(); ReflectionTestUtils.setField(historyService,"mapper",operationHistory); + ReflectionTestUtils.setField(service,"mapper",traced); ReflectionTestUtils.setField(service,"mutationRepository",new PdaPalletOperationRepository(traced)); + ReflectionTestUtils.setField(service,"physicalPalletSerial",observedSerial); ReflectionTestUtils.setField(service,"physical",physical); + ReflectionTestUtils.setField(service,"relabelApplications",applications); ReflectionTestUtils.setField(service,"relabelItems",sessions.getMapper(RelabelItemMapper.class)); + ReflectionTestUtils.setField(service,"returnCartons",sessions.getMapper(ReturnInventoryCartonMapper.class)); + ReflectionTestUtils.setField(service,"positions",sessions.getMapper(InventoryPositionMapper.class)); ReflectionTestUtils.setField(service,"balances",sessions.getMapper(InventoryBalanceMapper.class)); + ReflectionTestUtils.setField(service,"ledgers",sessions.getMapper(InventoryLedgerMapper.class)); ReflectionTestUtils.setField(service,"inventoryCommands",sessions.getMapper(InventoryCommandMapper.class)); + ReflectionTestUtils.setField(service,"history",historyService); + ReflectionTestUtils.setField(service,"relabelWorkTasks",sessions.getMapper(PdaRelabelTaskMapper.class)); ReflectionTestUtils.setField(service,"relabelWorkHistory",histories); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service,"mutationClock",Clock.fixed(AT.toInstant(ZoneOffset.UTC),ZoneOffset.UTC)); + service.configureTransactions(manager); + when(physical.resolve("TARGET")).thenReturn(target()); + when(physical.resolve("SOURCE")).thenReturn(new PalletPhysicalGateway.PalletReference(30,"SOURCE",88,null,"OCCUPIED","SCK8",0)); + when(physical.resolve("OTHER")).thenReturn(new PalletPhysicalGateway.PalletReference(50,"OTHER",88,null,"OCCUPIED","SCK8",0)); + when(physical.apply(anyLong(),any(),anyString(),any(),anyString(),anyString())).thenAnswer(call -> transition(call.getArgument(1),call.getArgument(2),call.getArgument(4))); + } + + /** 实体仅决定需映射的列集合,列名和类型必须实际存在于当前统一DDL。 */ + private void createEntity(Class type) { + var columns = new ArrayList(); + for (var field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue; + var mapping = field.getAnnotation(TableField.class); if (mapping != null && !mapping.exist()) continue; + String name = mapping != null && !mapping.value().isBlank() ? mapping.value() : field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT); + columns.add(name); + } + createColumns(type.getAnnotation(TableName.class).value(),columns); + } + + /** 从真实DDL提取所需普通列,支持同一行多列;隔离表只省略与本专项无关的约束。 */ + private void createColumns(String table,List requested) { + var block = Pattern.compile("(?is)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?`?"+Pattern.quote(table)+"`?\\s*\\((.*?)\\)\\s*ENGINE").matcher(baseline); + assertTrue(block.find(),"当前基线缺表:"+table); + String body = block.group(1).replaceAll("'(?:''|[^'])*'"," "); + var columns = new LinkedHashMap(); + var matcher = Pattern.compile("(?i)(?:\\A|,)\\s*`?([a-z_][a-z0-9_]*)`?\\s+(BIGINT|TINYINT|SMALLINT|INTEGER|INT|VARCHAR|CHAR|DECIMAL|NUMERIC|DATETIME|TIMESTAMP|DATE|LONGTEXT|TEXT|JSON|BOOLEAN|DOUBLE|FLOAT|BIT)(\\s*\\(\\s*\\d+(?:\\s*,\\s*\\d+)?\\s*\\))?").matcher(body); + while (matcher.find()) { + String type = matcher.group(2).toUpperCase(Locale.ROOT)+Objects.toString(matcher.group(3),""); + // H2隔离表保留JSON列名,以文本承载既有Jackson字符串,不验证MySQL JSON存储行为。 + if (type.equals("JSON")) type="LONGTEXT"; + columns.put(matcher.group(1).toLowerCase(Locale.ROOT),type); + } + var definitions = new ArrayList(); + for (String column : requested) { + assertTrue(columns.containsKey(column),"测试列不存在于当前真实DDL:"+table+"."+column); + String definition = column+" "+columns.get(column); + if (column.equals("id") || table.equals("ll_physical_pallet_guard") && column.equals("warehouse_pallet_id")) definition += " PRIMARY KEY"; + if (column.equals("created_at") && table.equals("ll_physical_pallet_guard")) definition += " DEFAULT CURRENT_TIMESTAMP(3) NOT NULL"; + if (column.equals("detained") && table.equals("ll_order")) definition += " DEFAULT 0"; + definitions.add(definition); + } + db.execute("CREATE TABLE "+table+" ("+String.join(",",definitions)+")"); + } + + /** 建立真实申请及其独立履约订单身份。 */ + void application(long id,String no,String customer,long order,String fulfillment) { + db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); + db.update("INSERT INTO ll_relabel_application(id,relabel_no,customer_id,overseas_warehouse_id,lastleg_order_id,status,fulfillment_status) VALUES(?,?,?,'88',?,'READY_FOR_OUTBOUND',?)",id,no,customer,order,fulfillment); + } + + /** 构造原始退货订单、实收箱和无来源托盘的换标明细。 */ + void nativeSource(long id,String no,String customer,long carton,long originalOrder) { + application(id,no,customer,2000+id,"WAIT_PRE_DISPATCH"); + db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",originalOrder,customer); + db.update("INSERT INTO ll_return_order VALUES(?,?,?,?)",id,customer,"RT-"+id,originalOrder); + db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,source_receipt_id,source_return_no,pallet_code,carton_mark,relabel_application_id,status,total_quantity,destination_warehouse_code) VALUES(?,?,'88',?,?, 'UNBOUND',?,?,'RELABEL_PENDING',2,'SCK8')",carton,customer,500+id,"RT-"+id,"BOX-"+id,id); + item(10+id,id,customer,null,carton,"BOX-"+id); + db.update("INSERT INTO ll_relabel_pda_task(id,application_id,customer_id,status) VALUES(?,?,?,'COMPLETED')",900+id,id,customer); + } + + /** 使用最终基线物理绑定列准备计划,分配链同时保留plan_id和customer_id。 */ + void planned(long plan,String customer,long warehouse,long order,Long physicalPalletId,String palletCode) { + db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); + db.update("INSERT INTO ll_inbound_plan(id,customer_id,overseas_warehouse_id) VALUES(?,?,?)",plan,customer,warehouse); + db.update("INSERT INTO ll_inbound_plan_pallet(id,customer_id,plan_id,pallet_no,warehouse_pallet_id) VALUES(?,?,?,?,?)",plan+1000,customer,plan,palletCode,physicalPalletId); + db.update("INSERT INTO ll_inbound_plan_line(id,customer_id,order_id,plan_id) VALUES(?,?,?,?)",plan+2000,customer,order,plan); + db.update("INSERT INTO ll_inbound_plan_pallet_allocation(pallet_id,line_id,customer_id,plan_id) VALUES(?,?,?,?)",plan+1000,plan+2000,customer,plan); + } + + /** 真实库存余额保留其原始订单,跨客户位置各有独立行。 */ + void position(long id,String customer,long balance,long pallet,long order,long held) { + db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,order); + String code = pallet==40?"TARGET":pallet==30?"SOURCE":"OTHER"; + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,pallet_id,pallet_no,batch_no,destination_warehouse_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,?,?,'88',?,?,'TEST','SCK8',?,?,?,?,?,0)",id,customer,balance,pallet,code,held==0?1:0,held,0,0,0); + } + + /** 明细原始订单从真实余额身份取得,原生退货保持其独立箱身份。 */ + void item(long id,long app,String customer,Long position,Long carton,String mark) { + Long order = position == null ? null : db.queryForObject("SELECT b.inbound_order_id FROM ll_inventory_balance b JOIN ll_inventory_position p ON p.balance_id=b.id WHERE p.id=?",Long.class,position); + db.update("INSERT INTO ll_relabel_item(id,application_id,customer_id,source_inventory_position_id,return_carton_id,carton_mark,notified_package_count,source_inbound_order_id,destination_fba_warehouse_code) VALUES(?,?,?,?,?,?,1,?,'SCK8')",id,app,customer,position,carton,mark,order); + } + + /** 执行现有原生退货入口,不绕开三个分支共用的生产门禁。 */ + com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation bind(String no,String key) { + var app = applications.findAnyByRelabelNo(no); + String mark = db.queryForObject("SELECT carton_mark FROM ll_relabel_item WHERE application_id=?",String.class,app.getId()); + return service.relabelDisassemble(ACTOR,new PalletOperationRequests.RelabelDisassemble(key,no,null,new PalletOperationRequests.PalletExpectation("TARGET",0),List.of(mark),"TEST合成资料")); + } + + /** 在真实事务内单独观察生产门禁的全局锁序。 */ + void guard(String no) { tx.executeWithoutResult(status -> ReflectionTestUtils.invokeMethod(service,"lockMutationComposition",ACTOR,applications.findAnyByRelabelNo(no),Map.of("TARGET",target()))); } + + /** 普通组托通过公开既有服务入口执行,不伪造换标申请。 */ + com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation assemble(String source,long position,String key) { + return service.assemble(ACTOR,new PalletOperationRequests.Assemble(key,new PalletOperationRequests.PalletExpectation("TARGET",0), + List.of(new PalletOperationRequests.PalletExpectation(source,0)),List.of(new PalletOperationRequests.MoveLine(position,1,0)),"TEST合成资料")); + } + + /** 读取真实位置当前可用量。 */ + long available(long id) { return Objects.requireNonNull(db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=?",Long.class,id)); } + + /** 目标托全部客户贡献合计只用于测试核对。 */ + long targetAvailable() { return Objects.requireNonNull(db.queryForObject("SELECT COALESCE(SUM(available_packages),0) FROM ll_inventory_position WHERE pallet_id=40",Long.class)); } + + /** 读取真实独立意图行的最终分类,不从抛出的异常或Mock次数推定恢复成功。 */ + String operationStatus(String key) { return db.queryForObject("SELECT status FROM ll_pda_pallet_operation WHERE idempotency_key=?",String.class,key); } + + /** 解析实际落盘的唯一attempt结构,确认未回执事实没有随业务回滚消失。 */ + com.fasterxml.jackson.databind.JsonNode attempts(String key) throws Exception { + return new ObjectMapper().readTree(db.queryForObject("SELECT remote_transitions_json FROM ll_pda_pallet_operation WHERE idempotency_key=?",String.class,key)); + } + + /** 读取提交后的箱绑定,亦可在当前事务内验证回滚注入点。 */ + String cartonPallet(long id) { return db.queryForObject("SELECT pallet_code FROM ll_return_inventory_carton WHERE id=?",String.class,id); } + + /** 目标来自受控的真实资源接口回执,未伪造来源仓位。 */ + PalletPhysicalGateway.PalletReference target() { return new PalletPhysicalGateway.PalletReference(40,"TARGET",88,null,"OCCUPIED","SCK8",0); } + + /** 仅模拟跨服务响应,库存、明细、历史及本地锁仍走实际Mapper。 */ + PalletPhysicalGateway.Transition transition(String key) { return new PalletPhysicalGateway.Transition(key,40,"TARGET",88,"OCCUPIED","OCCUPIED",null,null,0,1,"APPLIED",false); } + + /** 按本次实际调用实托返回受控远端回执,不把目标托回执借给来源托。 */ + PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet,String status,String key) { + return new PalletPhysicalGateway.Transition(key,pallet.palletId(),pallet.palletCode(),pallet.warehouseId(),pallet.status(),status,pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,"APPLIED",false); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index 9fbfc5254..cdc8e8b34 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -10,6 +10,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; import com.baiyue.lastleg.palletops.model.PalletInventoryRow; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; @@ -59,6 +61,8 @@ class PdaPalletOperationServiceTest { assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); } @Mock private PdaPalletOperationMapper mapper; + @Mock private PdaPalletOperationRepository mutationRepository; + @Mock private PhysicalPalletSerialPort physicalPalletSerial; @Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Mock private InventoryPositionMapper positions; @Mock private InventoryBalanceMapper balances; @@ -77,6 +81,19 @@ class PdaPalletOperationServiceTest { service = new PdaPalletOperationService(); ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class)); ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "mutationRepository", mutationRepository); + ReflectionTestUtils.setField(service, "physicalPalletSerial", physicalPalletSerial); + // 既有单元测试只模拟本申请;真实全客户组成、SQL锁及竞争由持久化专项证明。 + lenient().when(mutationRepository.composition(anyString(), anyMap(), any())).thenAnswer(call -> { + java.util.Map pallets = call.getArgument(1); + RelabelApplication application = call.getArgument(2); + return new PdaPalletOperationRepository.Composition(application == null ? List.of() : List.of(application),List.of(),pallets.entrySet().stream() + .map(entry -> new PdaPalletOperationMapper.MutationPallet(entry.getKey(),entry.getValue())).toList(),List.of()); + }); + lenient().when(mutationRepository.lockApplication(anyString())).thenReturn(true); + lenient().when(relabelItems.selectById(anyLong())).thenAnswer(call -> relabelItems.selectList( + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper()).stream() + .filter(item -> java.util.Objects.equals(item.getId(),call.getArgument(0))).findFirst().orElse(null)); ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances); @@ -118,9 +135,12 @@ class PdaPalletOperationServiceTest { ReflectionTestUtils.setField(service, "relabelWorkTasks", workTasks); ReflectionTestUtils.setField(service, "relabelWorkHistory", workHistory); var app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); + app.setRelabelNo("RL-1"); app.setStatus("READY_FOR_OUTBOUND"); app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); app.setOverseasWarehouseId("88"); var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(700L); when(workTasks.find("100", 600L)).thenReturn(task); var item = new RelabelItem(); item.setReturnCartonId(801L); item.setCartonMark("BOX-1"); + item.setId(701L); item.setApplicationId(600L); item.setCustomerId("100"); + doReturn(item).when(relabelItems).selectById(701L); item.setDestinationFbaWarehouseCode("SCK8"); item.setNotifiedPackageCount(1); var box = new ReturnInventoryCarton(); box.setId(801L); box.setCustomerId("100"); box.setPalletCode("SOURCE"); box.setRelabelApplicationId(600L); box.setStatus("RELABEL_PENDING"); @@ -133,6 +153,7 @@ class PdaPalletOperationServiceTest { var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, "OCCUPIED", "SCK8", 1); var target = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L, "OCCUPIED", "SCK8", 2); when(returnCartons.lockId(801L)).thenReturn(box); + when(returnCartons.selectById(801L)).thenReturn(box); when(returnCartons.palletCartons("88", "TARGET")).thenAnswer(call -> "TARGET".equals(box.getPalletCode()) ? List.of(existing, box) : List.of(existing)); var request = new PalletOperationRequests.RelabelDisassemble("rebind-current", "RL-1", @@ -154,6 +175,11 @@ class PdaPalletOperationServiceTest { new PalletPhysicalGateway.LocationReference(1010L, "TARGET-LOC", "STORAGE", "ACTIVE")); when(physical.resolve("SOURCE")).thenReturn(source); when(physical.resolve("TARGET")).thenReturn(target); + AtomicReference operation = new AtomicReference<>(); + doAnswer(call -> { operation.set(call.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class)); + when(mapper.selectById(anyLong())).thenAnswer(call -> operation.get()); + when(physical.apply(eq(88L),eq(target),eq("OCCUPIED"),eq(1010L),eq("rebind-current"),anyString())) + .thenReturn(transition(target,"OCCUPIED","rebind-current")); var result = (com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation) ReflectionTestUtils.invokeMethod(service, "bindReturnRelabelPallet", identity(), app, List.of(item), source, target, request, false); @@ -324,7 +350,7 @@ class PdaPalletOperationServiceTest { .when(mapper).insert(any(PdaPalletOperation.class)); when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get()); when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any())) - .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4))); var request = new PalletOperationRequests.Assemble("idem-1", new PalletOperationRequests.PalletExpectation("TARGET", 2), @@ -399,7 +425,7 @@ class PdaPalletOperationServiceTest { when(physical.resolve("SOURCE")).thenReturn(sourcePallet); when(physical.resolve("TARGET")).thenReturn(targetPallet); when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any())) - .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4))); RelabelApplication application = new RelabelApplication(); application.setId(600L); application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); @@ -489,7 +515,7 @@ class PdaPalletOperationServiceTest { when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app); RelabelItem item = relabelItem(701L, "CARTON-A", 101L); when(relabelItems.selectList(any())).thenReturn(List.of(item)); - when(relabelItems.selectById(701L)).thenReturn(item); + doReturn(item).when(relabelItems).selectById(701L); InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L); InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L); AtomicReference target = new AtomicReference<>(); @@ -506,7 +532,7 @@ class PdaPalletOperationServiceTest { var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2); when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst); when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any())) - .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4))); when(mapper.content("88", 10L)).thenReturn(List.of()); when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages()); when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages()); @@ -620,8 +646,8 @@ class PdaPalletOperationServiceTest { id == 20L ? "IDLE" : "OCCUPIED", null, version); } private static PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet, - String targetStatus) { - return new PalletPhysicalGateway.Transition("op-" + pallet.palletId(), pallet.palletId(), pallet.palletCode(), + String targetStatus,String key) { + return new PalletPhysicalGateway.Transition(key, pallet.palletId(), pallet.palletCode(), pallet.warehouseId(), pallet.status(), targetStatus, pallet.locationId(), pallet.locationId(), pallet.version(), pallet.version() + 1, "APPLIED", false); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java index e411e71c4..23c7e4aa7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java @@ -4,7 +4,7 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import org.junit.jupiter.api.Test; @@ -34,9 +34,9 @@ class PdaOperationsOpenApiContractTest { } } assertNoServerOwnedFields(PdaInventoryMoveRequest.class); - assertNoServerOwnedFields(DispatchPdaRequests.Pick.class); - assertNoServerOwnedFields(DispatchPdaRequests.CompletePicking.class); - assertNoServerOwnedFields(DispatchPdaRequests.Depart.class); + assertNoServerOwnedFields(DispatchCommands.Pick.class); + assertNoServerOwnedFields(DispatchCommands.LoadingComplete.class); + assertNoServerOwnedFields(DispatchCommands.Depart.class); assertNoServerOwnedFields(DeliveryRequests.PdaPick.class); assertNoServerOwnedFields(DeliveryRequests.PdaPalletPick.class); assertNoServerOwnedFields(DeliveryRequests.PdaComplete.class); @@ -72,7 +72,9 @@ class PdaOperationsOpenApiContractTest { "/api/pda/v1/last-leg/dispatches:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick:", - "/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:", + "/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading:", + "/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:", + "/api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:", "/api/pda/v1/last-leg/outbound-tasks:", "/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:", @@ -81,7 +83,8 @@ class PdaOperationsOpenApiContractTest { "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:", "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"); for (String path : paths) assertTrue(yaml.contains(path), path); - assertEquals(34, paths.size()); + assertEquals(36, paths.size()); + assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:")); assertTrue(yaml.contains("enum: [LOCATION, PALLET]")); assertTrue(yaml.contains("enum: [APPROVE, REWORK]")); assertTrue(yaml.contains("Warehouse 权威实物托盘码")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java index 59fefbe2b..4e01a632f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java @@ -6,8 +6,10 @@ import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.baiyue.lastleg.dispatch.service.DispatchPdaQueryService; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.test.util.ReflectionTestUtils; @@ -45,7 +47,7 @@ class PdaOutboundDispatchContractTest { })); } } - assertTrue(Arrays.stream(DispatchPdaRequests.Pick.class.getRecordComponents()) + assertTrue(Arrays.stream(DispatchCommands.Pick.class.getRecordComponents()) .noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName()))); assertTrue(Arrays.stream(DeliveryRequests.PdaPick.class.getRecordComponents()) .noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName()))); @@ -55,19 +57,18 @@ class PdaOutboundDispatchContractTest { @Test void controllersPassOnlyTokenBoundWarehouseToServices() { - DispatchPdaWorkflowService dispatchService = mock(DispatchPdaWorkflowService.class); + DispatchPdaQueryService dispatchService = mock(DispatchPdaQueryService.class); DeliveryTransferService outboundService = mock(DeliveryTransferService.class); DispatchPdaController dispatch = new DispatchPdaController(); OutboundPdaController outbound = new OutboundPdaController(); - ReflectionTestUtils.setField(dispatch, "service", dispatchService); - ReflectionTestUtils.setField(dispatch, "trips", mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class)); + ReflectionTestUtils.setField(dispatch, "queries", dispatchService); ReflectionTestUtils.setField(outbound, "service", outboundService); AuthenticatedUser user = pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L); dispatch.list(user, null); outbound.list(user, null); - verify(dispatchService).list("900", null); + verify(dispatchService).list(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),null); verify(outboundService).pdaTasks("900", null); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java new file mode 100644 index 000000000..1d7f46504 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java @@ -0,0 +1,553 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService; +import com.baiyue.lastleg.relabel.mapper.RelabelDispatchSourceMapper; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** 隔离 H2 中执行真实 Mapper,验证闭包、箱数、轮次和调用方事务锁。 */ +class RelabelDispatchSourcePersistenceTest { + private JdbcTemplate jdbc; + private RelabelDispatchSourceService service; + private DataSourceTransactionManager transactionManager; + private TransactionTemplate transaction; + private final List lockCalls = new ArrayList<>(); + + /** 创建最小业务表并装配实际 MyBatis Mapper 与事务管理器。 */ + @BeforeEach + void prepare() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute(""" + CREATE TABLE ll_relabel_application ( + id BIGINT PRIMARY KEY, customer_id VARCHAR(40), relabel_no VARCHAR(60) UNIQUE, + lastleg_order_id BIGINT, overseas_warehouse_id VARCHAR(40), delivery_method VARCHAR(40), + status VARCHAR(40), fulfillment_status VARCHAR(40), updated_at TIMESTAMP, + customer_confirmed_at TIMESTAMP) + """); + jdbc.execute(""" + CREATE TABLE ll_relabel_item ( + id BIGINT PRIMARY KEY, application_id BIGINT, customer_id VARCHAR(40), + notified_package_count INT, total_package_count INT, carton_mark VARCHAR(80), + operation_type VARCHAR(40), sku_relabel_items_json VARCHAR(4000), return_carton_id BIGINT, + source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT, + source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80)) + """); + jdbc.execute(""" + CREATE TABLE ll_inventory_position ( + id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT, + overseas_warehouse_id VARCHAR(40), pallet_id BIGINT, pallet_no VARCHAR(80), + available_packages BIGINT, hold_packages BIGINT, allocated_packages BIGINT, + picked_packages BIGINT, receiving_packages BIGINT, version BIGINT) + """); + jdbc.execute(""" + CREATE TABLE ll_relabel_pda_task ( + id BIGINT PRIMARY KEY, application_id BIGINT, customer_id VARCHAR(40), + relabel_no VARCHAR(60), overseas_warehouse_id BIGINT, status VARCHAR(40), + reviewed_at TIMESTAMP, version INT) + """); + jdbc.execute(""" + CREATE TABLE ll_relabel_pda_history ( + id BIGINT PRIMARY KEY, task_id BIGINT, application_id BIGINT, customer_id VARCHAR(40), + action VARCHAR(40), detail_json VARCHAR(4000), created_at TIMESTAMP) + """); + jdbc.execute(""" + CREATE TABLE ll_relabel_pda_unit_fact ( + id BIGINT PRIMARY KEY, task_id BIGINT, application_id BIGINT, item_id BIGINT, + customer_id VARCHAR(40), old_label VARCHAR(80), new_label VARCHAR(80), + result VARCHAR(40), quantity INT, occurred_at TIMESTAMP) + """); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(RelabelDispatchSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setConfiguration(configuration); + RelabelDispatchSourceMapper actual = new SqlSessionTemplate(factory.getObject()) + .getMapper(RelabelDispatchSourceMapper.class); + // 只记录真实 Mapper 的锁调用次序,所有查询及 FOR UPDATE 仍交给实际数据库执行。 + RelabelDispatchSourceMapper observed = (RelabelDispatchSourceMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{RelabelDispatchSourceMapper.class}, (proxy, method, args) -> { + if (method.getName().equals("lockApplication")) lockCalls.add((String) args[0]); + try { return method.invoke(actual, args); } + catch (InvocationTargetException failure) { throw failure.getCause(); } + }); + service = new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(observed), new ObjectMapper()); + transactionManager = new DataSourceTransactionManager(dataSource); + transaction = new TransactionTemplate(transactionManager); + transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + position(101, 1001, "PALLET-1", 10); + application(1, "RL-B", 9001); + application(2, "RL-A", 9002); + item(11, 1, 101, "BOX-1", 3, 501, 7001); + item(21, 2, 101, "BOX-2", 4, 502, 7002); + binding(10, 1, "BOX-1", "PALLET-1", "INITIAL"); + binding(20, 2, "BOX-2", "PALLET-1", "INITIAL"); + fact(11, 1, 11, "RELABELLED"); + fact(12, 1, 11, "EXCEPTION"); + fact(21, 2, 21, "RELABELLED"); + } + + /** 两个申请共用同一位置时合计绑定数,不让每张申请单独通过 HOLD 校验。 */ + @Test + void combinedContributionsCannotExceedActualHold() { + jdbc.update("UPDATE ll_inventory_position SET hold_packages=6 WHERE id=101"); + Snapshot snapshot = snapshot(); + assertEquals(2, snapshot.applications().size()); + assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum()); + assertEquals(6, snapshot.positions().get(0).holdPackages()); + assertFalse(snapshot.complete()); + assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("HOLD"))); + } + + /** 逐明细冻结原入仓链保留独立身份,履约订单及当前余额不能替代原货源。 */ + @Test + void sourceProvenanceComesFromEachItem() { + Snapshot snapshot = snapshot(); + assertTrue(snapshot.complete(), snapshot.blockReasons().toString()); + var first = snapshot.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + var second = snapshot.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow(); + assertEquals(7001L, first.sourceInboundOrderId()); + assertEquals("IN-7001", first.sourceInboundOrderNo()); + assertEquals("CODE-7001", first.sourceInboundCode()); + assertEquals(501L, first.sourceInventoryBalanceId()); + assertEquals(7002L, second.sourceInboundOrderId()); + assertEquals(502L, second.sourceInventoryBalanceId()); + assertEquals(600L, first.inventoryBalanceId()); + assertEquals(9001L, snapshot.applications().stream().filter(a -> a.applicationId() == 1) + .findFirst().orElseThrow().fulfillmentOrderId()); + } + + /** 绑定、通知、成功及异常各自保留,历史数量不放大箱数,纯箱标件数未知。 */ + @Test + void workFactsAreGroupedBeforeCombiningAndCartonPiecesStayUnknown() { + binding(22, 2, "BOX-2", "PALLET-1", "INITIAL"); + Snapshot snapshot = snapshot(); + assertTrue(snapshot.complete(), snapshot.blockReasons().toString()); + assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::notifiedPackageCount).sum()); + assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum()); + var facts = snapshot.sources().stream().flatMap(source -> source.unitFacts().stream()).toList(); + assertEquals(3, facts.size()); + assertEquals(2, facts.stream().filter(f -> f.result().equals("RELABELLED")).count()); + assertEquals(1, facts.stream().filter(f -> f.result().equals("EXCEPTION")).count()); + assertTrue(snapshot.sources().stream().allMatch(source -> source.productQuantity() == null)); + } + + /** 商品件数只来自当前轮次完整的实际 SKU 扫描,不使用逐箱事实 quantity=1 推算。 */ + @Test + void skuPiecesRequireCurrentCompleteScanFacts() { + jdbc.update("UPDATE ll_relabel_item SET operation_type='SKU_LABEL', sku_relabel_items_json=? WHERE id=11", + "[{\"quantity\":5},{\"quantity\":7}]"); + skuScan(30, 0, 5, "INITIAL"); + assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + skuScan(31, 1, 7, "INITIAL"); + assertEquals(12L, snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + jdbc.update("UPDATE ll_relabel_pda_history SET detail_json=? WHERE id=31", + "{\"round\":\"INITIAL\",\"itemId\":\"11\",\"skuIndex\":1,\"quantity\":99}"); + assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + } + + /** 初轮 PDA 完工后客户确认改写复核时间,仍须保留 INITIAL 绑定与实际 SKU 件数。 */ + @Test + void customerConfirmationMustNotReplaceInitialWorkRound() { + jdbc.update("UPDATE ll_relabel_application SET status='CUSTOMER_CONFIRM_PENDING',customer_confirmed_at=NULL WHERE id=1"); + jdbc.update("UPDATE ll_relabel_item SET operation_type='CARTON_AND_SKU',sku_relabel_items_json=? WHERE id=11", + "[{\"quantity\":5},{\"quantity\":7}]"); + skuScan(30, 0, 5, "INITIAL"); + skuScan(31, 1, 7, "INITIAL"); + complete(50, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0)); + Snapshot before = snapshot(); + assertTrue(before.complete(), before.blockReasons().toString()); + assertEquals("INITIAL", before.applications().stream().filter(a -> a.applicationId() == 1) + .findFirst().orElseThrow().roundKey()); + var beforeSource = before.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + assertEquals(3, beforeSource.boundPackageCount()); + assertEquals(12L, beforeSource.productQuantity()); + + LocalDateTime confirmedAt = LocalDateTime.of(2026, 1, 5, 10, 30); + customerConfirm(1, confirmedAt); + Snapshot after = snapshot(); + assertTrue(after.complete(), after.blockReasons().toString()); + var application = after.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow(); + var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + assertEquals(confirmedAt, jdbc.queryForObject("SELECT reviewed_at FROM ll_relabel_pda_task WHERE application_id=1", LocalDateTime.class)); + assertEquals("INITIAL", application.roundKey()); + assertEquals(confirmedAt, application.customerConfirmedAt()); + assertEquals(3, source.boundPackageCount()); + assertEquals(12L, source.productQuantity()); + assertEquals(beforeSource.unitFacts(), source.unitFacts()); + assertNotEquals(before.compositionVersion(), after.compositionVersion()); + } + + /** 返工新轮次取实际绑定及完工历史,客户再次确认的时间不能替换返工作业轮次。 */ + @Test + void customerConfirmationPreservesReworkRoundAndRejectsPriorScans() { + jdbc.update("UPDATE ll_relabel_item SET operation_type='CARTON_AND_SKU',sku_relabel_items_json=? WHERE id=11", + "[{\"quantity\":5},{\"quantity\":7}]"); + skuScan(30, 0, 5, "INITIAL"); + skuScan(31, 1, 7, "INITIAL"); + complete(35, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0)); + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(40,1001,1,'C1','REWORK_ARCHIVE','{}','2026-01-05 00:00:00')"); + LocalDateTime reworkAt = LocalDateTime.of(2026, 1, 5, 0, 0, 1); + String round = reworkAt.toString(); + jdbc.update("UPDATE ll_relabel_pda_task SET reviewed_at=?,version=2 WHERE application_id=1", reworkAt); + jdbc.update("UPDATE ll_relabel_pda_unit_fact SET result='REWORK_ARCHIVED',old_label=NULL WHERE application_id=1"); + jdbc.update("UPDATE ll_relabel_application SET status='CUSTOMER_CONFIRM_PENDING',customer_confirmed_at=NULL WHERE id=1"); + binding(41, 1, "BOX-1", "PALLET-1", round); + jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:00' WHERE id=41"); + fact(42, 1, 11, "RELABELLED"); + jdbc.update("UPDATE ll_relabel_pda_unit_fact SET occurred_at='2026-01-06 00:00:00' WHERE id=42"); + complete(50, 1, round, LocalDateTime.of(2026, 1, 7, 0, 0)); + // 旧轮次两个 SKU 均已扫描,也不能作为返工新轮次的商品件数证据。 + assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + skuScan(51, 0, 5, round); + skuScan(52, 1, 7, round); + jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:01' WHERE id IN (51,52)"); + Snapshot before = snapshot(); + assertTrue(before.complete(), before.blockReasons().toString()); + assertEquals(round, before.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow().roundKey()); + assertEquals(12L, before.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + + LocalDateTime confirmedAt = LocalDateTime.of(2026, 1, 8, 10, 30); + customerConfirm(1, confirmedAt); + Snapshot after = snapshot(); + assertTrue(after.complete(), after.blockReasons().toString()); + assertEquals(confirmedAt, jdbc.queryForObject("SELECT reviewed_at FROM ll_relabel_pda_task WHERE application_id=1", LocalDateTime.class)); + assertEquals(round, after.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow().roundKey()); + var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + assertEquals(3, source.boundPackageCount()); + assertEquals(12L, source.productQuantity()); + assertEquals(List.of(42L), source.unitFacts().stream().map(RelabelDispatchSourcePort.UnitFact::id).toList()); + assertNotEquals(before.compositionVersion(), after.compositionVersion()); + } + + /** 返工后不能借用旧绑定和旧作业事实;其他申请的本轮事实完整保留。 */ + @Test + void reworkInvalidatesOldRoundBindingAndFacts() { + Snapshot before = snapshot(); + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(40,1001,1,'C1','REWORK_ARCHIVE','{}','2026-01-05 00:00:00')"); + jdbc.update("UPDATE ll_relabel_pda_task SET reviewed_at='2026-01-05 00:00:01', version=2 WHERE application_id=1"); + jdbc.update("UPDATE ll_relabel_pda_unit_fact SET result='REWORK_ARCHIVED',old_label=NULL WHERE application_id=1"); + Snapshot after = snapshot(); + var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + assertEquals(0, source.boundPackageCount()); + assertTrue(source.unitFacts().isEmpty()); + assertFalse(after.complete()); + assertNotEquals(before.compositionVersion(), after.compositionVersion()); + assertEquals(4, after.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow().boundPackageCount()); + binding(41, 1, "BOX-1", "PALLET-1", "INITIAL"); + jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:00' WHERE id=41"); + assertEquals(0, snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().boundPackageCount()); + } + + /** 闭包递归扩展到新实托上的其他申请及它们的其他实托,不能只返回首申请。 */ + @Test + void physicalApplicationClosureExpandsToFixedPoint() { + position(102, 1002, "PALLET-2", 50); + position(103, 1003, "PALLET-3", 50); + item(12, 1, 102, "BOX-12", 1, 503, 7003); + binding(30, 1, "BOX-12", "PALLET-2", "INITIAL"); + application(3, "RL-C", 9003); + item(31, 3, 102, "BOX-31", 1, 504, 7004); + item(32, 3, 103, "BOX-32", 1, 505, 7005); + binding(31, 3, "BOX-31", "PALLET-2", "INITIAL"); + binding(32, 3, "BOX-32", "PALLET-3", "INITIAL"); + application(4, "RL-D", 9004); + item(41, 4, 103, "BOX-41", 1, 506, 7006); + binding(41, 4, "BOX-41", "PALLET-3", "INITIAL"); + Snapshot snapshot = snapshot(); + assertTrue(snapshot.complete(), snapshot.blockReasons().toString()); + assertEquals(List.of("RL-A", "RL-B", "RL-C", "RL-D"), snapshot.applications().stream() + .map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertEquals(6, snapshot.sources().size()); + assertEquals(List.of(1001L, 1002L, 1003L), snapshot.positions().stream() + .map(RelabelDispatchSourcePort.Position::warehousePalletId).toList()); + } + + /** 不按已完成状态、确认时间或运输方式删减原始关联申请。 */ + @Test + void snapshotKeepsAllApplicationStatuses() { + for (String status : List.of("PENDING_REVIEW", "PENDING_PROCESSING", "PROCESSING", "CUSTOMER_CONFIRM_PENDING", + "READY_FOR_OUTBOUND", "REJECTED", "COMPLETED")) { + jdbc.update("UPDATE ll_relabel_application SET status=?,delivery_method='OTHER',customer_confirmed_at=NULL WHERE id=2", status); + Snapshot snapshot = snapshot(); + assertEquals(2, snapshot.applications().size()); + var app = snapshot.applications().stream().filter(a -> a.applicationId() == 2).findFirst().orElseThrow(); + assertEquals(status, app.status()); + assertEquals("OTHER", app.deliveryMethod()); + assertNull(app.customerConfirmedAt()); + assertEquals(2, snapshot.sources().size()); + } + } + + /** 仅验证取消后持久状态:旧 SKU 指针保留,但不再作为新贡献或连接另一托上的活跃申请。 */ + @Test + void cancelledSkuHistoryDoesNotContributeOrExpandThroughItsOtherPallet() { + cancelledSkuHistoryWithReleasedHold(); + Snapshot current = snapshot(); + assertTrue(current.complete(), current.blockReasons().toString()); + assertEquals(List.of("RL-A"), current.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertEquals(List.of(21L), current.sources().stream().map(RelabelDispatchSourcePort.BoundSource::itemId).toList()); + assertEquals(List.of(1001L), current.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).toList()); + assertEquals(4, current.positions().get(0).holdPackages()); + assertEquals(1, current.positions().get(0).availablePackages()); + Snapshot other = service.snapshot("1", List.of(1002L)); + assertTrue(other.complete(), other.blockReasons().toString()); + assertEquals(List.of("RL-C"), other.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertEquals(List.of(31L), other.sources().stream().map(RelabelDispatchSourcePort.BoundSource::itemId).toList()); + assertEquals(List.of(1002L), other.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).toList()); + assertEquals(5, other.positions().get(0).holdPackages()); + assertEquals(1, other.positions().get(0).availablePackages()); + assertEquals("CANCELLED", jdbc.queryForObject("SELECT status FROM ll_relabel_application WHERE id=1", String.class)); + assertEquals(List.of(101L, 102L), jdbc.queryForList( + "SELECT source_inventory_position_id FROM ll_relabel_item WHERE application_id=1 ORDER BY id", Long.class)); + } + + /** 取消历史退出闭包不能隐去残余 HOLD,真实运输冻结服务仍须拒绝并回滚已经插入的贡献。 */ + @Test + void cancelledHistoryCannotHideUnexplainedHoldFromActualTransportConsumer() throws Exception { + cancelledSkuHistoryWithReleasedHold(); + jdbc.update("UPDATE ll_inventory_position SET hold_packages=5,available_packages=0 WHERE id=101"); + Snapshot current = snapshot(); + assertTrue(current.complete(), current.blockReasons().toString()); + assertEquals(List.of("RL-A"), current.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertEquals(5, current.positions().get(0).holdPackages()); + assertEquals(4, current.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum()); + // provider 负责完整事实,实际运输消费者继续执行逐位置 HOLD 等量核对;这里不修改其规则。 + DispatchItemSourceService consumer = actualTransportConsumer(); + var root = new DispatchAggregate(); root.setId(77L); root.setOverseasWarehouseId("1"); + var selected = new DispatchItem(); selected.setId(88L); selected.setDispatchOrderId(77L); + selected.setCustomerId("C1"); selected.setOrderId(9002L); selected.setWarehousePalletId(1001L); + selected.setSourceBusinessType("RELABEL_ORDER"); selected.setSourceBusinessNo("RL-A"); + selected.setSourceStage("RELABEL"); selected.setPackageCount(4); + BizException blocked = assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> { + service.lockApplications(List.of("RL-A")); + consumer.capture(root, List.of(selected), List.of(selected), LocalDateTime.of(2026, 1, 6, 0, 0)); + })); + assertTrue(blocked.getMessage().contains("冻结箱数")); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item_source", Integer.class)); + assertEquals(5L, jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101", Long.class)); + } + + /** 位置版本、逐箱事实及位置移动都会改变组成摘要,不能复用旧绑定依据。 */ + @Test + void versionsReflectStockAndCompositionChanges() { + Snapshot original = snapshot(); + assertEquals(original.compositionVersion(), snapshot().compositionVersion()); + jdbc.update("UPDATE ll_inventory_position SET version=version+1 WHERE id=101"); + Snapshot stockChanged = snapshot(); + assertNotEquals(original.compositionVersion(), stockChanged.compositionVersion()); + assertNotEquals(original.sources().get(0).bindingBasisHash(), stockChanged.sources().get(0).bindingBasisHash()); + jdbc.update("UPDATE ll_relabel_pda_unit_fact SET new_label='CHANGED' WHERE id=21"); + Snapshot workChanged = snapshot(); + assertNotEquals(stockChanged.compositionVersion(), workChanged.compositionVersion()); + position(102, 1002, "PALLET-2", 50); + jdbc.update("UPDATE ll_relabel_item SET source_inventory_position_id=102 WHERE id=21"); + Snapshot moved = snapshot(); + assertNotEquals(workChanged.compositionVersion(), moved.compositionVersion()); + // 明细已经移出原托盘闭包;原托盘保留的申请仍有完整绑定,不能把已移走明细强塞回来。 + assertTrue(moved.complete(), moved.blockReasons().toString()); + assertEquals(List.of("RL-B"), moved.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertTrue(moved.sources().stream().noneMatch(s -> s.itemId() == 21)); + // 显式读取新托盘后,实际位置与旧绑定目标不符,必须保留该明细并拒绝旧绑定。 + Snapshot includingMovedPallet = service.snapshot("1", List.of(1001L, 1002L)); + assertFalse(includingMovedPallet.complete()); + assertEquals(0, includingMovedPallet.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow().boundPackageCount()); + } + + /** 无事务或未锁申请时必须拒绝锁后读取;锁顺序稳定且每事务只锁一次。 */ + @Test + void callerTransactionOwnsOneOrderedApplicationLockSet() { + assertThrows(IllegalStateException.class, () -> service.lockApplications(List.of("RL-A"))); + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))); + transaction.executeWithoutResult(status -> { + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))); + service.lockApplications(List.of("RL-B", "RL-A", "RL-B")); + assertEquals(List.of("RL-A", "RL-B"), lockCalls); + service.lockApplications(List.of("RL-A", "RL-B")); + assertEquals(2, lockCalls.size()); + assertEquals(2, service.lockedSnapshot("1", List.of(1001L)).applications().size()); + assertThrows(IllegalStateException.class, () -> service.lockApplications(List.of("RL-A"))); + }); + transaction.executeWithoutResult(status -> + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L)))); + } + + /** 锁后重读跳过 MyBatis 缓存,并拒绝锁集合以外新出现的申请。 */ + @Test + void lockedReadRefreshesRowsAndRejectsNewApplication() { + transaction.executeWithoutResult(status -> { + Snapshot before = snapshot(); + service.lockApplications(List.of("RL-A", "RL-B")); + jdbc.update("UPDATE ll_inventory_position SET version=2 WHERE id=101"); + Snapshot after = service.lockedSnapshot("1", List.of(1001L)); + assertEquals(2, after.positions().get(0).recordVersion()); + assertNotEquals(before.compositionVersion(), after.compositionVersion()); + application(3, "RL-C", 9003); + item(31, 3, 101, "BOX-31", 1, 503, 7003); + binding(31, 3, "BOX-31", "PALLET-1", "INITIAL"); + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))); + }); + } + + /** 被挂起事务的锁凭证不能被新事务借用,恢复和回滚后凭证按事务清除。 */ + @Test + void suspendedOrCompletedTransactionCannotLendItsLockProof() { + transaction.executeWithoutResult(status -> { + service.lockApplications(List.of("RL-A", "RL-B")); + var nested = new TransactionTemplate(transactionManager); + nested.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + nested.executeWithoutResult(inner -> + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L)))); + assertTrue(service.lockedSnapshot("1", List.of(1001L)).complete()); + status.setRollbackOnly(); + }); + transaction.executeWithoutResult(status -> { + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))); + service.lockApplications(List.of("RL-A", "RL-B")); + assertTrue(service.lockedSnapshot("1", List.of(1001L)).complete()); + }); + } + + /** 客户隔离或绑定历史损坏时输出事实并显式阻止完整性声明。 */ + @Test + void invalidOwnershipOrBindingHistoryBlocksCompleteness() { + jdbc.update("UPDATE ll_relabel_item SET customer_id='C2' WHERE id=21"); + jdbc.update("UPDATE ll_relabel_pda_history SET detail_json='broken' WHERE id=10"); + Snapshot snapshot = snapshot(); + assertEquals(2, snapshot.sources().size()); + assertFalse(snapshot.complete()); + assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("归属"))); + assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("历史"))); + } + + /** 仅用隔离 SQL 准备正式取消后的状态,不调用 changeStatus 或声称完整取消、财务链已经验收。 */ + private void cancelledSkuHistoryWithReleasedHold() { + position(102, 1002, "PALLET-2", 6); + item(12, 1, 102, "BOX-12", 1, 503, 7003); + binding(30, 1, "BOX-12", "PALLET-2", "INITIAL"); + application(3, "RL-C", 9003); + item(31, 3, 102, "BOX-31", 5, 504, 7004); + binding(31, 3, "BOX-31", "PALLET-2", "INITIAL"); + jdbc.update("UPDATE ll_relabel_item SET operation_type='SKU_LABEL',notified_package_count=1,total_package_count=1," + + "sku_relabel_items_json='[{\"quantity\":5}]' WHERE application_id=1"); + jdbc.update("DELETE FROM ll_relabel_pda_unit_fact WHERE id=12"); + fact(13, 1, 12, "RELABELLED"); + skuScan(90, 0, 5, "INITIAL"); + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(91,1001,1,'C1','SKU_SCAN',?,'2026-01-03 00:00:00')", + "{\"round\":\"INITIAL\",\"itemId\":\"12\",\"skuIndex\":0,\"quantity\":5}"); + complete(92, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0)); + jdbc.update("UPDATE ll_inventory_position SET hold_packages=5,available_packages=0 WHERE id=101"); + jdbc.update("UPDATE ll_relabel_application SET status='CANCELLED',fulfillment_status='WAIT_CUSTOMER_CONFIRMATION'," + + "delivery_method=NULL,customer_confirmed_at=NULL WHERE id=1"); + jdbc.update("UPDATE ll_inventory_position SET hold_packages=hold_packages-1,available_packages=available_packages+1," + + "version=version+1 WHERE id IN (101,102)"); + } + + /** 新反例复用真实运输 Mapper、Repository、Service 和同一 H2 事务,不使用模拟拒绝门禁。 */ + private DispatchItemSourceService actualTransportConsumer() throws Exception { + jdbc.execute(""" + CREATE TABLE ll_dispatch_item_source ( + id BIGINT PRIMARY KEY,dispatch_order_id BIGINT NOT NULL,dispatch_item_id BIGINT NOT NULL, + customer_id VARCHAR(128) NOT NULL,relabel_application_id BIGINT NOT NULL,relabel_item_id BIGINT NOT NULL, + relabel_round_key VARCHAR(128) NOT NULL,warehouse_pallet_id BIGINT NOT NULL, + inventory_position_id BIGINT NOT NULL,inventory_balance_id BIGINT NOT NULL, + source_inventory_balance_id BIGINT,source_inbound_order_id BIGINT,source_inbound_order_no VARCHAR(128), + source_inbound_code VARCHAR(128),notified_package_count BIGINT NOT NULL,package_count BIGINT NOT NULL, + product_quantity BIGINT,binding_basis_hash CHAR(64) NOT NULL,unit_facts_json CLOB NOT NULL,created_at TIMESTAMP NOT NULL, + UNIQUE(dispatch_item_id,relabel_item_id,inventory_position_id)) + """); + var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(DispatchItemSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(transactionManager.getDataSource()); factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchItemSourceMapper.class); + return new DispatchItemSourceService(new DispatchItemSourceRepository(mapper), service, new ObjectMapper().findAndRegisterModules()); + } + + /** 创建同客户申请及独立作业任务,不借用履约订单作为原货源。 */ + private void application(long id, String no, long fulfillmentOrderId) { + jdbc.update("INSERT INTO ll_relabel_application VALUES(?,'C1',?,?,'1','TRUCK','READY_FOR_OUTBOUND'," + + "'WAIT_PRE_DISPATCH','2026-01-04 00:00:00','2026-01-04 00:00:00')", id, no, fulfillmentOrderId); + jdbc.update("INSERT INTO ll_relabel_pda_task VALUES(?,?,'C1',?,1,'WAIT_REVIEW',NULL,1)", 1000 + id, id, no); + } + + /** 创建真实库存位置,保留可用、HOLD 和版本的独立事实。 */ + private void position(long id, long palletId, String palletNo, long hold) { + jdbc.update("INSERT INTO ll_inventory_position VALUES(?,'C1',600,'1',?,?,0,?,0,0,0,1)", + id, palletId, palletNo, hold); + } + + /** 创建逐明细独立冻结来源的普通箱标任务。 */ + private void item(long id, long appId, long positionId, String mark, int packages, long sourceBalance, long inbound) { + jdbc.update("INSERT INTO ll_relabel_item VALUES(?,?,'C1',?,?,?,'CARTON_LABEL',NULL,NULL,?,?,?,?,?)", + id, appId, packages, packages, mark, sourceBalance, positionId, inbound, "IN-" + inbound, "CODE-" + inbound); + } + + /** 写入专用组托的当前轮次历史,箱唛与实托码严格配对。 */ + private void binding(long id, long appId, String mark, String palletNo, String round) { + String detail = "{\"round\":\"" + round + "\",\"cartonMarks\":[\"" + mark + + "\"],\"targetPallets\":[\"" + palletNo + "\"]}"; + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,?,?,'C1','PALLET_BIND',?,'2026-01-02 00:00:00')", + id, 1000 + appId, appId, detail); + } + + /** 写入箱级成功或异常事实,quantity=1 明确表示箱作业而非商品件数。 */ + private void fact(long id, long appId, long itemId, String result) { + jdbc.update("INSERT INTO ll_relabel_pda_unit_fact VALUES(?,?,?,?,'C1',?,?,?,1,'2026-01-03 00:00:00')", + id, 1000 + appId, appId, itemId, "OLD-" + id, "NEW-" + id, result); + } + + /** 写入商品件数的实际逐 SKU 扫描证据。 */ + private void skuScan(long id, int index, int quantity, String round) { + String detail = "{\"round\":\"" + round + "\",\"itemId\":\"11\",\"skuIndex\":" + index + + ",\"quantity\":" + quantity + "}"; + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,1001,1,'C1','SKU_SCAN',?,'2026-01-03 00:00:00')", id, detail); + } + + /** 写入生产者 COMPLETE 的 roundKey,不以客户确认时更新的任务字段替代。 */ + private void complete(long id, long appId, String round, LocalDateTime occurredAt) { + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,?,?,'C1','COMPLETE',?,?)", id, 1000 + appId, + appId, "{\"roundKey\":\"" + round + "\"}", occurredAt); + } + + /** 按正式客户确认路径重写 reviewedAt、任务状态及申请确认字段。 */ + private void customerConfirm(long appId, LocalDateTime confirmedAt) { + jdbc.update("UPDATE ll_relabel_pda_task SET status='COMPLETED',reviewed_at=?,version=version+1 WHERE application_id=?", + confirmedAt, appId); + jdbc.update("UPDATE ll_relabel_application SET status='READY_FOR_OUTBOUND',fulfillment_status='WAIT_PRE_DISPATCH'," + + "customer_confirmed_at=?,updated_at=? WHERE id=?", confirmedAt, confirmedAt, appId); + } + + /** 从一个实托调用实际生产事实提供者。 */ + private Snapshot snapshot() { + return service.snapshot("1", List.of(1001L)); + } +} diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 56d09c370..fc1243a00 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -295,6 +295,41 @@ "source": "com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticController", "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", "reason": "内部只读数量诊断复用服务间身份守卫,不依赖客户库存与出库业务。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentExecutionAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentExecutionAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchContracts", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentExecutionAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchTypes", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchCommands", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchContracts", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchTypes", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" } ], "applicationDependencyRule": { diff --git a/scripts/README.md b/scripts/README.md index 23eea8f66..fc50c4861 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -57,7 +57,7 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`: `fba-remaining-nodes-acceptance-pda-operator.mjs` 是 F33 海外 PDA 的一次性 TEST 账号准备助手,导入不运行。显式调用 `createF33PdaOperator({env, credentialFile, reportFile})`,凭据来自已授权 TEST 环境;仅通过正式 IAM API 创建独立洛杉矶仓作业员、授予已存在的海外 PDA 角色并验证登录/仓绑定,不改角色权限或旧账号,不操作库存。随机凭据仅写入调用方指定的仓库外环境文件,报告建议保存 `reports/fba-remaining-nodes-20261004/pda-operator/`,不包含密码或 Token;已有文件、请求未知结果或首次强制改密均停止,禁止自动重造。退出自有管理员/PDA会话,账号保留供页面验收,报告给出精确停用接口,清理由总控在验收结束后执行。 -`fleet-same-route-cost-test-env.mjs` 是同路线自有/外接实运成本分摊的串行TEST入口,默认`--plan`零网络;执行参数为`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据仅通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用两新客户普通来货/PDA实收/客户确认,各一托一箱,再复用真实外接预订、来货、PDA实收与关联,三托统一目的站;逐单拣货、实际装载和审核后整车发车。登记USD1.00合成成本,以不可变DEPART实测托位、三票来源及业务号顺序独立核对最大余数分币、原币守恒、创建重放与唯一历史,再逐票签收、正式结案并分别释放车辆/司机。自有两票分别分摊,不能先合并自有托位后假设汇总一定为0.67;余分按现行逐业务号规则分配。不写业务SQL、不部署、不操作财务付款。报告沿`reports/fleet-cross-<新批次>/`,增加`same-route-source.json`和`createdResources.fleetSameRouteCost`;入口要求验收函数显式返回本轮车次VERIFIED才能结案,不借另一证据对象的状态。失败保留原请求/样本、停止且不自动重跑,finally停用自有PDA并退出会话。`fleet-same-route-cost-test-env.test.mjs`验证错误来源/目的地/冻结依据/分币及零网络计划;真实HTTP与页面结论分别记录。 +`fleet-same-route-cost-test-env.mjs` 是同路线自有/外接实运成本分摊的串行TEST入口,默认`--plan`零网络;执行参数为`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据仅通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。两新客户普通来货/PDA实收/客户确认各一托一箱,共用一张原生排车;真实外接预订、来货和运营确认实收后加到原主单,三托统一目的站,零扫码退回后重新两审、按各客户实拣、实际装载、平台逐组安排,再以当前版本整车发车。登记USD1.00合成成本,以不可变DEPART实测托位核对两条业务分摊:OWN按本张排车单汇总两实托,EXTERNAL按真实booking业务号计外接实托,两行共用同一dispatchNo。实际容量2:1时为0.67/0.33,非单位容量按实际比例及业务号顺序分配最大余数,不按托盘个数或预设比例替代。保留每客户来源、库存/SHIP、原币分币守恒、成本创建重放与唯一历史,逐派送单去重签收、正式结案并分别释放车辆/司机。不写业务SQL、不部署、不操作财务付款。报告沿`reports/fleet-cross-<新批次>/`,增加`same-route-source.json`和`createdResources.fleetSameRouteCost`;显式SAME_ROUTE_COST校验域要求本轮marker、排车、车次及原proof一致,不能借跨客户或容量池结果结案。失败保留原请求/样本、停止且不自动重跑,缺实测先阻断,finally停用自有PDA并退出会话。`fleet-same-route-cost-test-env.test.mjs`验证错误来源/目的地/冻结依据/分币及零网络计划;真实HTTP与页面结论分别记录。 `fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<用户指定目录的test-acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 @@ -89,16 +89,18 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`: F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_CANCELLED` 只运行显式指定的一种顺序,省略仍串行执行三种;报告和成功范围仅覆盖所列模式。`--verify-delivery-event-recovery` 必须同时显式指定一种顺序,且环境为TEST;仅在该模式第一张真实FBA派送签收前开启定向ACK丢失,不能使用普通海外转运的签收替代国内回写。故障窗口会重建同镜像Lastleg并临时调整事件目标和退避配置,禁止与其他部署或共享环境写入并行;原事件DEAD恢复后必须验证原镜像/配置/网络身份、健康及代理退出。入口事先留存精确故障scope,finally兜底恢复,缺恢复证据或恢复校验失败一律失败。完整库存取证接入既有只读器,报告保留于该批次 `evidence/`,不能把某一模式或事件通过解读为全部反向事件完成。 -`fleet-cross-customer-test-env.mjs` 是最小两客户共享车次的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,分别预排并关联同车;一单未拣齐时验证全车拒绝及完整库存快照不变,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 +`fleet-cross-customer-test-env.mjs` 是最小两客户同一排车主单的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,真实来源一次Create到同一主单,自动唯一Fleet记录、配置资源并完成两审;一客户未核平时验证整车装车/发车拒绝及完整库存快照不变,全量实拣后按真实测量显式装车、平台逐客户目的地安排,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,缺实测不补计划值,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。单票验收的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 ## 验收证据校验 -`fleet-trip-rollback-test-env.mjs` 是跨客户整车发车中途回滚的串行TEST入口,默认`--plan`零网络;实际参数沿用`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据只通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用正式两客户一托一箱来货/实收/排车链,全部合法拣货和理货审核后,在第二客户原托盘锁行建立65秒SELECT FOR UPDATE租约,以默认50秒锁等待超时使原整车发车事务失败;不修改业务行、Schema、共享参数或镜像。严格证明原等待图及第一客户尚未提交的DEPARTED/OUTBOUNDED/重放记录,再按一致性完整快照证明全部回滚;READ UNCOMMITTED仅用于明确标识的中途观察,不作为最终库存事实。租约自动ROLLBACK并核对无等待,再用同一原发车键/载荷完成正式发车、重放、逐客户签收和资源释放。报告沿`reports/fleet-cross-<新批次>/`,额外`rollback.json`、源码SHA与镜像身份保留;失败停止、原现场和请求保留、不自动重造或续跑,finally仅清理自有PDA/会话。共享TEST窗口禁止并行发布或操作相同库存。对应`fleet-trip-rollback-test-env.test.mjs`验证证据反例和范围;通用跨客户入口不默认故障注入。 +`fleet-trip-rollback-test-env.mjs` 是跨客户同一原生主单发车中途回滚的串行TEST入口,默认`--plan`零网络;实际参数沿用`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据只通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用正式两客户一托一箱普通转仓来货/实收、同主单两审/实拣/实测装车及平台安排链;按Java客户字符串顺序,通过原业务号只读解析当前主单、客户子单及真实转仓来源,第二客户转仓行建立65秒SELECT FOR UPDATE租约,等待事务自身的50秒会话锁超时使原整车发车失败。不修改业务行、Schema、共享参数或镜像;GLOBAL超时或HTTP超时不能代替会话与事务证据。等待图须精确对应原发车事务、目标转仓主键及实际UPDATE,同时独立READ UNCOMMITTED观察已写的第一客户子单、来源计数及历史;此时主单仍AFTER_LOADING、Fleet为PLANNED、尚无DEPART命令结果,不能沿用旧DEPARTED/重放表断言。最终新已提交读按实际Schema全字段比较相关主/子单、来源历史、占用、命令账本及已提交扫码/SHIP事实,脏读仅作中途证明。租约自动ROLLBACK,核对原事务和等待均已消失,再用同一原发车键/载荷完成正式发车、重放、逐客户签收和资源释放。报告沿`reports/fleet-cross-<新批次>/`,额外`rollback.json`、源码SHA与镜像身份保留;权限、字段、会话超时或任一证据缺失明确阻断,失败保留原现场,不自动重造或续跑,finally仅清理自有PDA/会话。执行需要对应TEST数据库取证、持锁及真实HTTP的明确授权,共享窗口禁止并行发布或操作相同库存。对应`fleet-trip-rollback-test-env.test.mjs`验证证据反例和范围;通用跨客户入口不默认故障注入。 `fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 +整车回滚专项的完整快照覆盖当前23表,包括本批真实实托的`ll_physical_pallet_guard`永久串行行;按实际Schema全列及COUNT取证,中途和回滚后均核对原主键与首次创建时间不变,不把删除重建串行点视作成功回滚。此取证范围仅限该回滚专项,不扩展通用车辆入口的故障注入。 + 该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,默认要求全新批次;仅下述精确候选失败检查点允许显式恢复原样本。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。 经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。 @@ -157,7 +159,7 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-acceptance.mjs --apply --confirm-production-ddl ``` -`mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜出库续跑入口:参数为 `source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`,凭据由显式 `node --env-file` 注入。FBA 分支实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;一件代发分支只走专用候选、两托四箱出库、PDA 幂等拣货和完成回查,并明确记录派送未覆盖,不返回全流程成功退出码。finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它不代表普通海外仓目的地转换或所有分支签收验收通过。 +`mixed-container-outbound-test-env.mjs` 是经明确授权的TEST留存混柜出库续跑入口,参数为`source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`;显式环境文件使用统一Gateway/Auth,拒绝其他环境。FBA分支仅从本次报告中真实收货的客户、仓、来源和marker创建原生排车,顺序为两级审核、PDA实拣、真实测量装车、平台全车派送安排、当前主单版本发车、POD和资源结案。续跑须保留来源报告SHA、本轮marker、原Create载荷及原生CommandResult/Detail,旧报告或裸排车号不能证明来源;没有实测托位时明确阻断,不使用计划常量。一件代发仍为专用两托四箱出库及PDA幂等拣货/完成,明确派送未覆盖且不返回全流程成功退出码。写前原子落盘,结果未知停止,不换键或删除报告重跑;finally停用临时账号,保留业务资源。离线合同`native-retained-outbound-contract.test.mjs`覆盖两个留存入口,导入无网络;不能代替真实HTTP或PDA验收。 全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。 @@ -187,6 +189,7 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep - 完整正常链:`full-flow <外部订单输入.json> <独立全流程检查点.json> --confirm-single-full-flow [--resume]`。仅支持 `BUY_DECLARATION` 买单报关、单 SKU、1~2 箱、义乌起运及洛杉矶接柜的 FBA 正常链。先用现有客户下单审核,再测量/费用确认、订舱/分柜、国内 PDA 装柜、买单报关、人工船节点、目的港提柜预约、海外 PDA 收货/还空柜、排车/派送、车次调度/拣货/发车、签收回传及车次结案释放资源。任何HTTP或归属/数量门禁失败都停止,保留同票检查点,不新建订单代替失败。 - 全流程内部模块:`prod-release-full-flow.mjs` 负责单票阶段编排和独立子检查点;`prod-release-http.mjs` 负责统一 Gateway、写前请求摘要落盘、失败诊断、合成文件上传及独立登录会话退出;`prod-release-domestic-stage.mjs` 负责国内正常链;`prod-release-overseas-stage.mjs` 负责海外正常链和签收后车次结案。这些文件没有独立CLI,不运行全量fixture,不创建账号、客户、车辆、司机、供应商等主档。正常业务会创建本票货柜、订舱、作业托盘、排车、车次、派送及费用记录。 - 完整输入还需 `domestic: {palletId, assigneeId, bookingAgent:{id,name}, carrier:{id,name,carrierCode}, terminalFleet:{id,name}, estimatedSailingAt, estimatedArrivalAt}` 和 `resources:{vehicleId,driverId,carrierName}`,均引用既有启用且有资格的资源。订单级及货物行都必须明确提供 `shipmentId/referenceId`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。 +- 实际装载缺少托位等实测值时,在首次输入中预先指定 `loadingMeasurementFile` 私有绝对路径;该路径随完整输入指纹冻结,文件可在本票扫码核平后补齐,再以同一输入和检查点 `--resume`。文件为 JSON:`{environment:"TEST"|"GRAY",batch,dispatchNo,pallets:[{warehousePalletId,palletSpaces,weightKg,volumeCbm,deliveryIndex,evidence:[{field,actual:true,sourceType,sourceBusinessNo,observedAt,value}]}]}`,环境须与本次入口一致,三项测量各有实际来源、时间和值,实托和路线须匹配当前装载预览。缺失、格式错误或归属不符停止装车,不从计划或计费常量补值。装车意图前保存原文件 SHA、回执和最终测量;成功命令恢复原请求,结果未知停写,不用修改文件触发重发。内部直接调用的 `loadingMeasurements` 与文件输入互斥。 - 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,其范围仅为 `ONE_ORDER_NORMAL_HTTP_PATH`。测量、资质、装柜、卸柜和签收文件均明确为合成验收材料,不代表真实物流证据;不覆盖退税报关、多客户/拆单/异常、故障注入、真实支付、第三方下单、邮件、浏览器及设备验收。阶段检查点已完成命令复用原结果,未知写入意图必须人工核对,不能自动重发。 - 离线验证:`node --test scripts/prod-release-acceptance.test.mjs scripts/prod-release-order-stage.test.mjs scripts/prod-release-delivery-stage.test.mjs scripts/prod-release-measurement-plan.test.mjs scripts/prod-release-http.test.mjs scripts/prod-release-domestic-stage.test.mjs scripts/prod-release-overseas-stage.test.mjs scripts/script-documentation-contract.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。最终输出单独报告 `sessionCleanup`,会话退出不受业务请求次数上限影响。 @@ -489,10 +492,12 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 - `db/seed-local-relabel-ready-orders.sql`:仅供明确授权的本机测试库存准备。调用者在同一 MySQL 会话提供客户、共享海外仓、有效虚拟库位、FBA目的仓、唯一批次及ID基数;凭据由本机进程环境提供。以事务插入三单共54箱、六托及原入仓快照、计划、明细、余额、仓位和明确标记 LOCAL_TEST_FIXTURE 的库存流水;不伪造PDA照片、签收操作或HTTP验收通过,不修改原数据。重复批次失败并回滚,不用upsert重置已消耗库存;成功数据保留,结果表输出到调用者日志,不自动清理。 - `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。履约阶段中断后可显式设置 `OVERSEAS_RESUME_FULFILLMENT=true`,复用目录内已通过的 `relabel.json` 且只续跑出库派送。只允许本机开发环境且须有当前轮真实写入授权。 -- `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。 +- `scripts/overseas-relabel-real-acceptance.mjs`:仅显式 TEST 的统一 Gateway/Auth 换标生产者,会创建换标、库存及原生排车记录;平台/PDA及可选客户登录凭据由显式环境文件提供,缺客户凭据时通过正式临时登录票据,票据和 Token 只留内存。运行前必须指定 `OVERSEAS_TEST_CUSTOMER_ID`、`OVERSEAS_TEST_WAREHOUSE_ID`、`OVERSEAS_RELABEL_SOURCE_BALANCE_ID`、`OVERSEAS_RELABEL_SOURCE_POSITION_ID`,可用 `OVERSEAS_RELABEL_SOURCE_INBOUND_CODE` 进一步限定本轮来源;不任意借历史库存。`OVERSEAS_RELABEL_MARKER` 可固定批次,报告默认 `reports/overseas-relabel-real-acceptance.json`(`OVERSEAS_ACCEPTANCE_OUTPUT` 覆盖),已有报告禁止覆盖或重造 marker,失败保留原请求和资源,不自动清理、重跑或续跑,执行另需对应环境授权。保留四种换标作业、新旧箱唛、实物归属与扫码/打印重放;原生排车前一次读取本仓完整RELABEL候选,核对服务端selectionGroup、组成版本、全部逻辑来源与实托。NEW-PALLET和MERGE-EXISTING共托时共用一次Create及原键重放,并分别保留换标来源贡献;SPLIT-TWO-TARGETS仍按整申请闭包独立建单。未知成员、缺组、版本漂移或测量冲突必须在首个Create前阻断,不能退回只拒绝所有合托的旧行为。 +- 可选 `OVERSEAS_RELABEL_LOADING_EVIDENCE` 指向按场景名索引的私有 JSON 回执,每项明确 `{environment:"TEST",marker,dispatchNo,pallets:[{warehousePalletId,palletSpaces,weightKg,volumeCbm,deliveryIndex,evidence:[{field,actual:true,sourceType,sourceBusinessNo,observedAt,value}]}]}`;当前排车、实托及三个实际测量来源须完全匹配,缺项标记装载证据阻断,不虚构托位。共享组各场景若同时提供回执,必须指向同一排车并具有相同实托测量;整托重量、体积及容量只计一次。`scripts/overseas-relabel-real-contract.test.mjs` 仅离线验证显式来源、四场景证据、完整组共车Create、原键重放、冲突前置阻断及未知写停写,不代表真实作业验收。该入口产出原生排车证明,既有留存消费者仍只继续SPLIT场景,不据此宣称共享组审核、扫码、装车、发车及签收全链已验收。 - 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。 - `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。 -- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。 +- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:显式TEST环境读取`OVERSEAS_ACCEPTANCE_SOURCE`中SPLIT-TWO-TARGETS的本轮原生Create命令、响应/详情及新旧箱唛证明,裸排车号或旧预排报告被拒绝。使用统一Gateway/Auth及与混柜相同的原生审核、扫码、真实装车、平台安排、发车、POD和资源链;缺物理测量不造值,未知写入保留原键停写。报告默认`reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT`覆盖),来源SHA和原命令必须保留,失败不自动清理。它不接管仅开发环境的current-session包装,亦不替代Admin全量验收。 +- `scripts/native-retained-outbound-contract.test.mjs`:仅离线校验上述混柜/换标入口的来源身份、原生命令、写前落盘与未知停写、实载/PDA顺序、SHIP数量、POD和箱唛护栏;不读取环境凭据、不发HTTP、不执行SQL或部署。命令为`node --test scripts/native-retained-outbound-contract.test.mjs`。 - `scripts/warehouse-transfer-remaining-acceptance.mjs`:本地开发环境仓储转运真实 HTTP 验收入口,创建并推进转运、换标和库存业务数据,凭据来自环境变量或 `local-env.ps1`,报告写入 `reports/warehouse-transfer-remaining-acceptance.json`;失败数据保留,不自动清理。 - `scripts/warehouse-transfer-resume-acceptance.mjs`:续跑既有仓储转运排车、出库和派送流程,会推进真实业务状态;通过脚本内既有单据或环境变量定位待续跑数据,报告写入 `reports/warehouse-transfer-resume-acceptance.json`,不自动清理。 - `scripts/warehouse-transfer-receipt-acceptance.mjs`:本地开发环境目的仓收货真实 HTTP 验收,创建或通过 `WAREHOUSE_TRANSFER_RECEIPT_NO` 续跑库存转运并写入目的仓库存;凭据来自环境变量或 `local-env.ps1`,报告默认写入 `reports/warehouse-transfer-receipt-acceptance.json`。失败资源保留,续跑会复用同一报告中已创建的目的仓托盘,不自动清理。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 2fdcca5df..6b7facf2e 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -32,9 +32,9 @@ `fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。 -`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 +`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物,再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 -`fleet-cross-customer-acceptance.mjs` 是跨客户共享车次的内部后半链验收函数,不是独立入口。默认调用只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收并分别预排/转派送、关联同一合格车次且未拣货,以及调用方提供的完整库存只读取证器。逐客户审核和拣货,验证第二单未就绪时整车拒绝且全部快照不变,再验证统一发车、原键重放和分别签收。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 +`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量显式装车、平台逐客户目的地安排,再以当前主单版本统一发车、原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 `TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。 @@ -54,11 +54,11 @@ 头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。 -`fleet-trip-fulfillment.mjs` 是内部车次验收助手,不是独立入口。五期与F40模块通过正式API建立本轮车辆容量、测试司机及PNG证照上传/审核、车次关联、目的站匹配及本单调度审核通过;拣货后确认明确标记为测试的实物测量,通过整车理货审核后携带最新车次版本统一发车。资源及文件ID保留到报告,不自动删除、不以SQL修改状态;失败停止,不能跳过资格或装载。`mixed-container-outbound-test-env.mjs` 与 `overseas-relabel-fulfillment-acceptance.mjs` 的留存场景须在拣货前已有正式车次关联和通过的调度审核,不能对已经开工的旧单补造关联;它们确认受控测试装载、理货审核并继续履约。改派检查保留拣货事实,但重新通过每张单的调度审核后再确认装载。`fleet-trip-fulfillment-contract.test.mjs` 离线验证重复实托、目的地及准备顺序,不代表HTTP验收。 +`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;五期与F40通过正式API维护本轮车辆、司机及证照,提交并通过调度/理货两级审核,再读取该排车唯一自动生成的Fleet资源记录。PDA拣货核平后,显式确认装车完成,再由平台安排全车客户目的地派送,最后使用当前主单版本发车;不再独立创建、绑定或审核Fleet车次。实载托位、重量、体积及卸货站须有本轮真实来源证据,不能使用计费量或固定值;ID和版本无损保留,Long箱数字符串仅在安全数量边界归一化。资源及文件ID留在报告,失败保留原命令。直接调用者也须同步消费原生DTO及该顺序;合同通过不代表HTTP或PDA硬件验收。 -`prepareFleetTrip` 创建的本轮车次必须完成发车前改派验证:`confirmFleetLoading` 先确认装载,再创建等容量替换车辆和经文件上传/审核的替班司机,经正式分配接口改派。回查客户关联、原货物、拣货数量、路线和到达时间未改变,原装载确认已清除;旧版本确认须被拒绝且不产生写入,最后按新版本重新确认。返回最终版本供既有 PDA 就绪检查和整车发车使用。报告 `createdResources.fleetTrips[].reassignment` 留存前后快照、替换资源、原请求和结果,只有全部通过才标记 `VERIFIED`;失败保留 `CHECKING`,同一证明对象禁止自动重试或重造。顶层资源引用随后更新为替换资源供结案/释放验证,原资源仍保留在快照中。留存场景不执行这段新建资源与改派流程。 +`prepareFleetTrip` 对零扫码的装车中排车,须先显式退回装车前再维护资源并重走两级审核;已扫码时不能重排。`confirmFleetLoading` 验证已扫码编辑被拒绝且主单与实际货物零漂移,然后按真实装载预览/实测回执发送装车完成命令;PDA成功后,平台使用同一原键原载荷重放,必须保持原结果和版本。报告`createdResources.fleetTrips[].reassignment`保留原请求、前后快照及拒绝证据;未知写结果停止,不自动换键重造。装车返回版本不替代发车前重新读取的公共版本。 -五期和F40签收后继续调用 `finishFleetTrip`:正式结案并同键重放,通过两个无货计划及一辆隔离用测试车辆验证“结案仍占用车辆、只结束车辆仍占用司机、两项结束后可重新分配、旧结束请求重放不能清除新占用”。仅409不足以通过,必须是相应资源占用冲突;全部完成后以正式取消命令取消无货计划并保留历史。测试车辆/司机在末站结束,不含返程任务;地点、时间、原请求及计划号保存于 `createdResources.fleetTrips[].closure`。失败保留CHECKING及原资源,不自动重造或修复旧fixture;离线模拟通过不等于这些真实HTTP场景已执行。 +五期和F40签收后继续调用`finishFleetTrip`:货物结案、车辆任务结束、司机任务结束分别执行并重放,按真实资源占用所有者核对“结案仍占用车辆、只结束车辆仍占用司机”。两项结束后,通过原生Create创建本轮空排车取得下一趟资源占用,再重放旧结束请求,证明不能清除新占用;不调用退役Fleet空计划创建接口。只有当前空排车无来源/无箱/无扫码且创建结果明确时,才能按最新版本取消探针;结果未知不清理。地点、时间、原请求及业务号留在`createdResources.fleetTrips[].closure`,不含返程任务,离线模拟不能替代真实环境验收。 `phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。 @@ -72,7 +72,7 @@ `phase4-overseas-pda.mjs` 的收货入口对本轮 `containerBookingId + containerNo` 做有界候选收敛等待(最多 30 次、间隔 1 秒),只有唯一且可操作的原柜才继续;鉴权失败、畸形响应和重复候选立即阻断。它用于等待异步船节点投影,不构成事件重放证据。F40 在成功和失败时均保留 `receivingCandidateEvidence` 的逐次查询身份、结果和诊断信息,不覆盖已有收货会话,不借其他柜或历史柜继续。 -多目的地排车 C52:`phase4-overseas-dispatch.mjs` 在本轮实际收货产生的同仓同客户托盘上验证多 FBA 目的地及重复目的地托盘的增加、移除、重放和拒绝后零漂移。路线期望从实际排车明细及本轮冻结候选独立推导,记录 `routeLifecycle`,最终恢复原主链排车并释放额外测试托盘;缺少至少两个目的地及同目的地双托前置时明确阻断,不借历史数据或用单目的地代替。复用 candidates、draft-locks/lock、pre-arrange、详情和 DELETE items 正式 API;定向入口仍为 `--modules phase4-overseas-dispatch`,会写 TEST,使用新报告路径,失败保留样本,不自动删除重跑。合同为 `phase4-overseas-dispatch-contract.test.mjs`;离线成功不代表实际 HTTP 生命周期通过。 +多目的地排车C52:`phase4-overseas-dispatch.mjs`在本轮实际收货产生的同仓同客户托盘上验证多FBA目的地及重复目的地托盘的增加、移除、原键重放和拒绝后零漂移。路线期望从真实Detail、Items、Route与冻结候选独立推导,记录`routeLifecycle`;缺少两个目的地及同目的地双托时明确阻断。使用版本化服务器草稿/容器/来源占用、`drafts/pre-arrange`转换、原生Create、`POST /{dispatchNo}/items/{dispatchItemId}/remove`和`PUT /{dispatchNo}/route`,不使用旧独立pre-arrange或DELETE items。正式交接保留原生三个读取对象及实际sourceKeys,停在ACTIVE/BEFORE_LOADING,未装车不得标记完成;实测缺失另行记录。写前原子落盘,未知结果停写,同marker不能重新创建。定向入口仍为`--modules phase4-overseas-dispatch`;真实运行会写指定TEST,仍须环境授权,失败保留原样本。两份对应合同验证脚本编排,不能证明服务端路线已正确同步或真实HTTP生命周期通过。 船节点异常 C66:`phase3-carrier-schedule-queries.mjs` 的 `captureCarrierExceptionEvidence` 与 `verifyCarrierExceptionTransition` 对本轮原船任务采集只读 checkpoint,核对 CUSTOMS_HOLD/TERMINAL_HOLD/DOCUMENT_HOLD/UNKNOWN_HOLD 的追加、解除、重放及冲突前后正常实际/预计节点守恒。`phase3-carrier-exceptions-contract.test.mjs` 只验证离线比对逻辑;真实生命周期还需要 Integration 供应商结果经调度器消费的独立投递证据。默认 HTTP 链只采基线并保留该覆盖缺口;空异常数组、重复 GET、报关异常和合成快照均不能计为供应商异常生命周期通过。 diff --git a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs index d96efd803..cf8534bc8 100644 --- a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { verifyFleetBookingReservation } from './fleet-booking-reservation-acceptance.mjs' +import { verifyFleetBookingReservation, testPlannedCapacity } from './fleet-booking-reservation-acceptance.mjs' +import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs' function fixture(fault = '') { const baseline = [ @@ -71,16 +72,12 @@ function fixture(fault = '') { return result(booking) }, } - const measurements = (actual, stops) => { - assert.deepEqual(actual, cargo); assert.deepEqual(stops, tripProof.stops) - return cargo.map((row, index) => ({ warehousePalletId: row.warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: index + 1 })) - } - return { context, tripProof, measurements, calls, bookings, assertions } + return { context, tripProof, cargo, calls, bookings, assertions } } test('reservation HTTP helper verifies actual segments, original requests and cancellation without receiving or charging', async () => { const f = fixture() - const proof = await verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements) + const proof = await verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof) assert.equal(proof.status, 'VERIFIED') assert.equal(proof.bookings.length, 2) assert.ok(f.bookings.every(row => row.status === 'CANCELLED')) @@ -89,13 +86,14 @@ test('reservation HTTP helper verifies actual segments, original requests and ca assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) assert.ok(f.calls.every(row => !/incoming|receipt-confirmation|transfer-delivery|departure/.test(row.suffix))) assert.equal(proof.createRequest.customerId, '900719925474099301') - await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements), /本轮新建/) + assert.equal(proof.planningBasis.kind, 'CONTROLLED_TEST_PLAN'); assert.equal(proof.planningBasis.actual, false) + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof), /本轮新建/) }) for (const fault of ['wrong-segment', 'replay-reoccupies', 'cancel-does-not-release', 'fake-loaded', 'missing-trace', 'wrong-conflict']) { test(`reservation helper fails closed on ${fault} even when HTTP reports success`, async () => { const f = fixture(fault) - await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements)) + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof)) assert.equal(f.tripProof.reservation.status, 'CHECKING') assert.ok(f.assertions.some(row => !row.passed && row.options.deploymentBlocker)) if (['wrong-segment', 'replay-reoccupies'].includes(fault)) assert.equal(f.calls.some(row => row.suffix.endsWith('/cancel')), false) @@ -104,6 +102,16 @@ for (const fault of ['wrong-segment', 'replay-reoccupies', 'cancel-does-not-rele test('reservation helper rejects foreign fixture before making any request', async () => { const f = fixture() - await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', { ...f.tripProof }, f.measurements), /本轮新建/) + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', { ...f.tripProof }), /本轮新建/) assert.equal(f.calls.length, 0) }) + +test('planned capacity counts a cross-customer physical pallet once and cannot be treated as actual measurement', () => { + const f = fixture(), first = f.cargo[0] + const planned = testPlannedCapacity([first, { ...first, customerId: 'OTHER-CUSTOMER' }], f.tripProof.stops) + assert.equal(planned.length, 1); assert.equal(planned[0].warehousePalletId, first.warehousePalletId) + assert.throws(() => controlledPalletMeasurements(planned, f.tripProof.stops), /实际预览|实际托盘/) + assert.throws(() => testPlannedCapacity([first, { ...first, destinationKey: 'FBA:B' }], f.tripProof.stops), /不同计划卸货站/) + assert.throws(() => testPlannedCapacity([{ ...first, warehousePalletId: null }], f.tripProof.stops), /缺少真实/) + assert.throws(() => testPlannedCapacity([first], [{ name: 'Origin' }]), /唯一路线/) +}) diff --git a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs index ff8651c1e..5a21a83e4 100644 --- a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs @@ -10,8 +10,23 @@ const version = value => /^(0|[1-9]\d*)$/.test(text(value)) const trace = response => ({ http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) +/** 仅生成明确TEST计划占用;这些预计值不提供真实装载测量证据。 */ +export function testPlannedCapacity(cargo, stops) { + const pallets = new Map() + if (!Array.isArray(cargo) || !cargo.length || !Array.isArray(stops)) throw new Error('计划容量缺少本轮真实货物或路线') + for (const row of cargo) { + if (!row.warehousePalletId || !row.palletNo || !row.destinationKey) throw new Error('计划货物缺少真实实托和目的地') + const matches = stops.map((stop, index) => stop.destinationKey === row.destinationKey ? index : -1).filter(index => index > 0) + if (matches.length !== 1) throw new Error('计划货物目的地不在唯一路线卸货站') + const palletId = text(row.warehousePalletId), deliveryIndex = matches[0] + if (pallets.has(palletId) && pallets.get(palletId).deliveryIndex !== deliveryIndex) throw new Error('同一实托存在不同计划卸货站') + pallets.set(palletId, { warehousePalletId: palletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex }) + } + return [...pallets.values()] +} + /** Internal HTTP helper. Operates only on the current run's trip before picking; no SQL or cleanup on failure. */ -export async function verifyFleetBookingReservation(context, module, tripProof, measurements) { +export async function verifyFleetBookingReservation(context, module, tripProof) { if (!context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof.tripNo || tripProof.reservation) throw new Error('外接占位验收仅接受本轮新建且尚未开始验收的车次;失败后保留原证据') const proof = tripProof.reservation = { status: 'CHECKING', marker: context.marker, requests: [], bookings: [] } @@ -37,7 +52,9 @@ export async function verifyFleetBookingReservation(context, module, tripProof, await check('自有货容量来源完整', initial?.cargo?.length > 0 && !initial.blockReason && initial.cargo.every(row => row.customerId && row.warehousePalletId && row.destinationKey) && version(initial.recordVersion) && /^[0-9a-f]{64}$/.test(initial.manifestHash || '')) - const pallets = measurements(initial.cargo, tripProof.stops) + const pallets = testPlannedCapacity(initial.cargo, tripProof.stops) + proof.planningBasis = { kind: 'CONTROLLED_TEST_PLAN', actual: false, pallets, + remark: '仅预计每实托1托位/100kg/1m³,用于计划余位测试;不得作为装车完成实测回执' } const plan = await request('POST', '/capacity-plan', { recordVersion: initial.recordVersion, manifestHash: initial.manifestHash, pallets, reason: '受控测试计划:按本轮真实实托确认预计占用,非生产测量', diff --git a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs index fcc783607..cf5a5f619 100644 --- a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs @@ -58,7 +58,9 @@ test('customer identity joins use the same explicit collation as existing fulfil test('commercial schemas do not invent container or palletizing facts for pool orders', () => { for (const name of ['ll_local_looseboard_order']) { const definition = table(overseas, 'baiyue_lastleg', name) - assert.match(definition, /container_type\s+varchar\(32\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i) + assert.doesNotMatch(definition, /container_type/i) + assert.match(definition, /estimated_pallet_count INT NULL/i) + assert.match(definition, /product_name VARCHAR\(255\) NULL/i) assert.doesNotMatch(definition, /container_type[^\n]*DEFAULT '40HQ'/i) } assert.doesNotMatch(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /container_type/i) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index 4361d3982..ff615f14d 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -3,69 +3,104 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { verifyFleetCrossCustomer, fixtureContract } from './fleet-cross-customer-acceptance.mjs' -function model(fault='') { - const copy=value=>structuredClone(value),calls=[],keys=new Set() - const members=[0,1].map(i=>({marker:'RUN',customerId:`C${i}`,incomingNo:`IN${i}`,dispatchNo:`DS${i}`,outboundNo:`OUT${i}`,deliveryNo:`DL${i}`, - balanceId:`B${i}`,positionId:`P${i}`,palletId:`PAL${i}`,palletNo:`PALLET${i}`,receiptVerified:true})) - const fixture={marker:'RUN',stage:fixtureContract.stage,tripNo:'TRIP',warehouseId:'WH',pdaToken:'not-a-real-token',members} - const trip={trip:{tripNo:'TRIP',warehouseId:'WH',status:'PLANNED',recordVersion:'0',vehicleId:'V',driverId:'D',stops:[{name:'Origin'}, - {name:'A',destinationKey:'FBA:A'},{name:'B',destinationKey:'FBA:B'}]},dispatches:members.map(m=>({dispatchNo:m.dispatchNo,customerId:m.customerId,review:{status:'PENDING'}}))} - const state=members.map(m=>({dispatch:{dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'PENDING_DEPARTURE',packageCount:1,pickedPackageCount:0, - items:[{id:`ITEM${m.customerId}`,warehousePalletId:m.palletId}]},delivery:{order:{deliveryNo:m.deliveryNo,customerId:m.customerId,dispatchNo:m.dispatchNo,outboundNo:m.outboundNo,status:'WAIT_DELIVERY',version:'0'}}, - outbound:{outboundNo:m.outboundNo,dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'WAIT_OUTBOUND'}, - stock:{scope:{marker:'RUN',customerId:m.customerId,warehouseId:'WH',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'}, - balances:[{id:m.balanceId,customerId:m.customerId,overseasWarehouseId:'WH',availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0',inTransitPackages:'0',procurementPackages:'0'}], - positions:[{id:m.positionId,customerId:m.customerId,overseasWarehouseId:'WH',balanceId:m.balanceId,palletId:m.palletId,palletNo:m.palletNo,availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0'}],ledger:[]}})) - const cargo=members.map((m,i)=>({dispatchNo:m.dispatchNo,customerId:m.customerId,warehousePalletId:m.palletId,palletNo:m.palletNo,destinationKey:i?'FBA:B':'FBA:A',packages:1})) - const response=data=>({http:200,body:{code:'200',data:copy(data)},requestId:'request',traceId:'trace'}) - const context={marker:'RUN',token:'admin',createdResources:{},assert(_m,label,_r,condition){assert.ok(condition,label)}, - async request(method,raw,body,token){ - calls.push({method,raw,body:copy(body),token});const path=new URL(raw,'https://offline.invalid').pathname - let index=members.findIndex(m=>path.includes(m.dispatchNo)||path.includes(m.outboundNo)||path.includes(m.deliveryNo)) - const row=state[index],member=members[index] - if(path.endsWith('/review')){trip.dispatches[index].review.status='APPROVED';trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)} - if(path.endsWith('/loading-review')){trip.loadingReview={status:'APPROVED'};trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)} - if(path.endsWith('/loading')){if(method==='POST')trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response({recordVersion:trip.trip.recordVersion,manifestHash:'HASH',cargo,blockReason:null})} - if(path.endsWith('/TRIP'))return response(trip) - if(path.endsWith('/pick')){ - if(keys.has(body.idempotencyKey))return response({replayed:true}) - keys.add(body.idempotencyKey);row.dispatch.pickedPackageCount=1 - row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0' - row.stock.ledger=['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:member.customerId,overseasWarehouseId:'WH',positionId:member.positionId,businessOrderNo:member.dispatchNo,businessType:type,deltaPackages:type==='SHIP'?'-1':'1'})) - if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger[2],id:'EXTRA'}) - return response({replayed:false}) +function model(fault = '') { + const copy = value => value == null ? value : structuredClone(value), calls = [], keys = new Map() + const members = [0,1].map(i => ({ marker: 'RUN', customerId: `C${i}`, incomingNo: `IN${i}`, dispatchNo: 'DS-ONE', dispatchItemId: `ITEM${i}`, + balanceId: `B${i}`, positionId: `P${i}`, palletId: `PAL${i}`, palletNo: `PALLET${i}`, receiptVerified: true })) + const stops = [{ name: 'Origin' }, { name: 'A', destinationKey: 'FBA:A' }, { name: 'B', destinationKey: 'FBA:B' }] + const tripProof = { marker: 'FT-CURRENT', dispatchNo: 'DS-ONE', tripNo: 'TRIP', warehouseId: 'WH', vehicleId: 'V', driverId: 'D', stops, + reassignment: { status: 'PENDING' }, updateRequest: { plannedDepartureAt: new Date(Date.now()+3600000).toISOString() } } + const actualEvidence = (field,value,i) => ({ field, value, actual: true, sourceType: 'WAREHOUSE_TRANSFER', sourceBusinessNo: `IN${i}`, observedAt: '2026-10-19T00:00:00Z' }) + const fixture = { marker: 'RUN', stage: fixtureContract.stage, dispatchNo: 'DS-ONE', tripNo: 'TRIP', tripProof, warehouseId: 'WH', pdaToken: 'not-a-real-token', members, + loadingMeasurements: { marker: 'RUN', environment: 'TEST', dispatchNo: 'DS-ONE', pallets: members.map((m,i) => ({ warehousePalletId: m.palletId, + palletSpaces: 1, deliveryIndex: i+1, evidence: [actualEvidence('palletSpaces',1,i)] })) } } + const trip = { trip: { tripNo: 'TRIP', warehouseId: 'WH', status: 'PLANNED', recordVersion: '0', vehicleId: 'V', driverId: 'D', stops }, dispatches: [{ dispatchNo: 'DS-ONE' }] } + let nativeVersion = 5, stage = 'LOADING', arranged = false + const state = members.map(m => ({ picked: 0, delivery: null, outbound: null, + stock: { scope: { marker: 'RUN', customerId: m.customerId, warehouseId: 'WH', coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, + balances: [{ id: m.balanceId, customerId: m.customerId, overseasWarehouseId: 'WH', availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0', inTransitPackages: '0', procurementPackages: '0' }], + positions: [{ id: m.positionId, customerId: m.customerId, overseasWarehouseId: 'WH', balanceId: m.balanceId, palletId: m.palletId, palletNo: m.palletNo, availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0' }], + ledger: [{ id: `STOCK-${m.customerId}`, customerId: m.customerId, overseasWarehouseId: 'WH', positionId: m.positionId, businessType: 'STOCK_IN', businessOrderNo: m.incomingNo, deltaPackages: '1' }] } })) + const counts = () => ({ itemCount: 2, palletCount: 2, packageCount: 2, pickedPackageCount: state.reduce((n,s) => n+s.picked,0), shippedPackageCount: ['DELIVERING','DELIVERED'].includes(stage) ? 2 : 0 }) + const items = () => members.map((m,i) => ({ dispatchItemId: m.dispatchItemId, customerId: m.customerId, warehousePalletId: m.palletId, + palletNo: m.palletNo, destination: { key: i ? 'FBA:B' : 'FBA:A' }, scheduledPackages: 1, pickedPackages: state[i].picked })) + const deliveryRefs = () => state.filter(s => s.delivery).map((s,i) => ({ customerId: members[i].customerId, deliveryNo: s.delivery.order.deliveryNo, + outboundNo: s.outbound.outboundNo, packageCount: 1, status: s.delivery.order.status, destination: { key: i ? 'FBA:B' : 'FBA:A' } })) + const detail = () => ({ dispatchNo: 'DS-ONE', status: 'ACTIVE', stage, recordVersion: nativeVersion, reviewState: 'APPROVED', + reviews: { dispatchReview: { status: 'APPROVED' }, loadingReview: { status: 'APPROVED' } }, counts: counts(), + documents: { deliveries: deliveryRefs(), outbounds: state.filter(s=>s.outbound).map(s=>s.outbound) } }) + const result = (body,payload,replayed = false) => ({ dispatchNo: 'DS-ONE', status: 'ACTIVE', stage, recordVersion: nativeVersion, + counts: counts(), idempotencyKey: body.idempotencyKey, result: payload, replayed }) + const response = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' }) + const reject = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' }) + const remember = (body,payload) => { const value = result(body,payload); keys.set(body.idempotencyKey,{body:copy(body),result:copy(value)});return response(value) } + const context = { marker: 'RUN', token: 'admin', createdResources: { fleetTrips: [tripProof] }, assert(_m,label,_r,condition) { assert.ok(condition,label) }, + apiSuccess(_m,_l,r) { return r.body.data }, + async request(method,raw,body,token) { + calls.push({method,raw,body:copy(body),token}); const url = new URL(raw,'https://offline.invalid'), path = url.pathname + if (method === 'POST' && keys.has(body?.idempotencyKey)) { + const prior = keys.get(body.idempotencyKey); assert.deepEqual(body,prior.body); return response({...prior.result,replayed:true}) } - if(path.endsWith('/complete')){row.dispatch.status='READY_TO_DEPART';return response({status:'READY_TO_DEPART'})} - if(path.endsWith('/depart')){ - if(state.some(s=>s.dispatch.pickedPackageCount!==1)){ - if(fault==='gate reorders route') [trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] - if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'} - return {http:fault==='outage disguised as rejection'?503:200,body:{code:fault==='outage disguised as rejection'?'S0503':'A0409',message:fault==='unrelated gate rejection'?'车次版本已变化':'DS1:请完成拣货并核平全部箱数'},requestId:'r',traceId:'t'} + if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) + if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()}) + if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') return method === 'PUT' ? reject('已经真实扫码,不能编辑资源') : response(detail()) + if (path.endsWith('/loading') && method === 'GET') return response({dispatchNo:'DS-ONE',status:'ACTIVE',stage,recordVersion:nativeVersion,manifestHash:'REAL-MANIFEST', + pickingReconciled:state.every(s=>s.picked===1),loadingComplete:{allowed:state.every(s=>s.picked===1)},pallets:members.map((m,i)=>({warehousePalletId:m.palletId,palletNo:m.palletNo, + scheduledPackages:1,pickedPackages:state[i].picked,palletSpaces:null,weightKg:100,volumeCbm:1,deliveryIndex:i+1, + evidence:[actualEvidence('weightKg',100,i),actualEvidence('volumeCbm',1,i)]}))}) + if (path.endsWith('/loading-complete')) { + if (!state.every(s=>s.picked===1)) { + if(fault==='outage disguised as rejection')return {http:503,body:{code:'S0503'},requestId:'r',traceId:'t'} + return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平') } - const replayed=keys.has(body.idempotencyKey);keys.add(body.idempotencyKey);trip.trip.status='IN_TRANSIT' - for(const [i,s] of state.entries())if(!(fault==='partial departure'&&i===1)){s.dispatch.status='DEPARTED';s.outbound.status='OUTBOUNDED'} - if(fault==='depart deducts again'){row.stock.balances[0].availablePackages='-1';row.stock.positions[0].availablePackages='-1'} - return response({tripNo:'TRIP',status:'IN_TRANSIT',shippedPackages:'2',dispatches:members.map(m=>({dispatchNo:m.dispatchNo})),replayed}) + assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2) + stage='AFTER_LOADING';nativeVersion++;return remember(body,{confirmedLoad:{manifestHash:body.manifestHash,pallets:body.measurements}}) } - if(path.includes('/api/pda/'))return response({dispatchNo:member.dispatchNo,warehouseId:'WH',tripNo:'TRIP',status:row.dispatch.pickedPackageCount?'COMPLETED':'PDA_PENDING', - items:[{dispatchItemId:`ITEM${member.customerId}`,palletNo:member.palletNo,scheduledPackages:1,pickedPackages:String(row.dispatch.pickedPackageCount), - positions:fault==='missing PDA position'?[]:[{id:member.positionId,balanceId:member.balanceId,availablePackages:row.stock.positions[0].availablePackages}]}]}) - if(path.includes('/dispatches/'))return response(row.dispatch) + if (path.endsWith('/pick')) { + const index=members.findIndex(m=>path.includes(`/items/${m.dispatchItemId}/`)),m=members[index],row=state[index] + assert.equal(body.recordVersion,nativeVersion);assert.equal(body.positionId,m.positionId);row.picked=1;nativeVersion++ + row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0' + row.stock.ledger.push(...['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:m.customerId,overseasWarehouseId:'WH',positionId:m.positionId,businessOrderNo:'DS-ONE',businessType:type,deltaPackages:type==='SHIP'?'-1':'1'}))) + if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger.at(-1),id:'EXTRA'}) + return remember(body,{dispatchItemId:m.dispatchItemId,pickedPackages:1}) + } + if (path === '/api/pda/v1/last-leg/dispatches/DS-ONE') return response({dispatchNo:'DS-ONE',warehouseId:'WH',status:'ACTIVE',stage,recordVersion:nativeVersion,counts:counts(),pickingReconciled:state.every(s=>s.picked===1), + items:items().map((item,i)=>({...item,positions:fault==='missing PDA position'?[]:[{positionId:members[i].positionId,balanceId:members[i].balanceId,availablePackages:state[i].stock.positions[0].availablePackages}]}))}) + if (path.endsWith('/delivery-arrangement')) return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,status:'ACTIVE',stage,cargoHash:'CARGO',loadingCompletionHash:'LOADED',arrange:{allowed:true}, + groups:members.map((m,i)=>({groupKey:`GROUP${i}`,customerId:m.customerId,groupCargoHash:`GROUP-HASH${i}`,destination:{key:i?'FBA:B':'FBA:A'},cargo:[{customerId:m.customerId,warehousePalletId:m.palletId}],blockReasons:[],requiredServices:['DELIVERY_FEE']}))}) + if (path.endsWith('/transfer-delivery')) { + assert.equal(stage,'AFTER_LOADING');assert.equal(body.recordVersion,nativeVersion);assert.equal(body.groups.length,2) + assert.ok(body.groups.every(g=>g.fees.length===1&&g.fees[0].amount===30)) + for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'WAIT_OUTBOUND'} + s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:'WAIT_DELIVERY',version:0}} } + arranged=true;nativeVersion++;return remember(body,{dispatchNo:'DS-ONE',outbounds:state.map((s,i)=>({...s.outbound,packageCount:1})),deliveries:deliveryRefs()}) + } + if (path.endsWith('/depart')) { + if(!arranged) { + if(fault==='gate reorders route')[trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] + if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'} + return reject('请先完成装车并一次安排全部客户派送') + } + assert.equal(body.recordVersion,nativeVersion);stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT' + for(const [i,s]of state.entries())if(!(fault==='partial departure'&&i===1)){s.outbound.status='OUTBOUNDED';s.delivery.order.status='DELIVERING'} + if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'} + return remember(body,{shippedPackages:2,departedAt:'2026-10-19T01:00:00Z',deliveries:deliveryRefs(),outbounds:state.map(s=>s.outbound)}) + } + const index=members.findIndex((_m,i)=>path.includes(`/OUT${i}`)||path.includes(`/DL${i}`)),row=state[index] if(path.includes('/outbound-orders/'))return response({outbound:row.outbound,deliveries:[row.delivery.order]}) - if(path.endsWith('/status')){row.delivery.order.status='DELIVERING';row.delivery.order.version=String(Number(row.delivery.order.version)+1);return response({deliveryStatus:'DELIVERING'})} - if(path.endsWith('/documents')){ + if(path.endsWith('/documents')) { if(method==='GET')return response({version:row.delivery.order.version,categories:[]}) - row.delivery.order.status='DELIVERED';row.delivery.order.version=String(Number(row.delivery.order.version)+1) + row.delivery.order.status='DELIVERED';row.delivery.order.version++ if(fault==='signs both')state[1-index].delivery.order.status='DELIVERED' + if(state.every(s=>s.delivery.order.status==='DELIVERED'))stage='DELIVERED' return response({version:row.delivery.order.version}) } - if(path.includes('/delivery-orders/'))return response(row.delivery) + if(path.includes('/delivery-orders/')) {assert.equal(url.searchParams.get('customerId'),members[index].customerId);return response(row.delivery)} throw new Error(`Unexpected ${method} ${path}`) - },async requestMultipart(method,path,body){calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})}} + },async requestMultipart(method,path) {calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})} } const readInventory=async scope=>{const value=copy(state[members.findIndex(m=>m.customerId===scope.customerId)].stock);if(fault==='wrong stock scope')value.positions[0].customerId='OTHER';return value} return {context,fixture,readInventory,calls,state} } - test('default call and import do not request, create or activate anything',async()=>{ let called=false;const result=await verifyFleetCrossCustomer({request(){called=true}},{}) assert.equal(result,fixtureContract);assert.equal(called,false) @@ -77,13 +112,25 @@ test('current DTO-shaped two-customer trip rejects unready gate, departs once an assert.deepEqual(m.state.map(s=>s.delivery.order.status),['DELIVERED','DELIVERED']) assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken)) assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken)) + assert.equal(new Set(proof.members.map(row=>row.dispatchNo)).size,1) + assert.equal(new Set(proof.members.map(row=>row.deliveryNo)).size,2) + assert.equal(new Set(proof.members.map(row=>row.outboundNo)).size,2) + assert.ok(!m.calls.some(c=>c.raw.endsWith('/complete')||c.raw.includes('/dispatches/bind'))) +}) + +test('missing physical pallet-space evidence blocks after both real picks without fabricating loading or delivery',async()=>{ + const m=model();delete m.fixture.loadingMeasurements + await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/palletSpaces/) + assert.ok(m.state.every(row=>row.picked===1&&row.delivery===null)) + assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1,'only the earlier expected unready rejection was sent') + assert.equal(m.calls.some(call=>call.raw.endsWith('/transfer-delivery')),false) }) test('explicit rollback verifier runs after both picks and before successful departure',async()=>{ const m=model();let calls=0 const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory, verifyDepartureRollback:async({ready,departPath,departBody})=>{ - calls++;assert.equal(ready.trip.trip.status,'PLANNED') - assert.ok(ready.members.every(row=>row.dispatch.status==='READY_TO_DEPART')) + calls++;assert.equal(ready.trip.trip.status,'PLANNED');assert.equal(ready.dispatch.stage,'AFTER_LOADING') + assert.ok(ready.members.every(row=>row.outbound.outbound.status==='WAIT_OUTBOUND')) assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.equal(departBody.idempotencyKey,'RUN-CROSS-DEPART') return {verified:true,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'} @@ -101,7 +148,7 @@ for(const failure of ['unverified','throws','mutates'])test(`rollback verificati assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) assert.ok(m.state.every(row=>row.delivery.order.status==='WAIT_DELIVERY')) }) -for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both']) { +for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both','foreign delivery']) { test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{ const m=model(fault);await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}), fault==='gate reorders route'?/Rejected departure leaves both complete inventories\/outbounds\/deliveries\/trip unchanged/:undefined) @@ -118,5 +165,5 @@ test('missing reader, reused customer and stale marker reject before any API req test('module contains no autonomous runner or SQL/environment/credential plumbing',async()=>{ const source=await readFile(new URL('./fleet-cross-customer-acceptance.mjs',import.meta.url),'utf8') assert.doesNotMatch(source,/process\.env|fetch\(|execSync|INSERT INTO|UPDATE ll_|createPicker|createMerchant/) - assert.match(source,/controlledPalletMeasurements/) + assert.match(source,/confirmFleetLoading/) }) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs index 9d7bc0a79..1ad6fc167 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs @@ -1,4 +1,4 @@ -import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs' +import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs' const MODULE = 'fleet-cross-customer-acceptance' const ROOT = '/api/platform/v1/last-leg' @@ -10,57 +10,55 @@ const encode = encodeURIComponent const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}` const canonical = value => JSON.stringify(normalize(value)) function normalize(value) { - // Route stops and other business arrays are ordered. Raw readers return stable ID order. if (Array.isArray(value)) return value.map(normalize) - return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])) : value + return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value } function integer(value) { - if (!['number','string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer') + if (!['number', 'string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer') return Number(value) } -const buckets = ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages'] -const balanceBuckets = [...buckets,'inTransitPackages','procurementPackages'] - +const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] +const balanceBuckets = [...buckets, 'inTransitPackages', 'procurementPackages'] export const fixtureContract = Object.freeze({ - stage: 'TWO_FRESH_RECEIVED_DISPATCHES_BOUND_AND_TRANSFERRED_NOT_PICKED', - fixture: 'marker, warehouseId, tripNo, pdaToken, members[2]: {marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified:true}', - preparation: 'Two independently qualified current-run customers; each formally received one physical pallet/box in the same warehouse; draft lock/pre-arrange separately; bind both to one assigned qualified vehicle/driver trip; transfer each to its own outbound and delivery. No picking yet.', - reader: 'readInventory(scope) => {scope:{marker,customerId,warehouseId,coverage:"CUSTOMER_WAREHOUSE_COMPLETE"}, balances:all raw rows including zero, positions:all raw rows including zero, ledger:all immutable movement rows}. Retain camelCase IDs and every quantity bucket; never substitute a grouped public SKU page.', - helperLimit: 'Existing external-receipt helper selects plan.cargo[0].customerId and requires an existing own-cargo capacity plan; external-dispatch helper has one mutable externalReceipt slot. Do not run either twice pretending to create two customers.', - boundary: 'This exercises a prerequisite gate rejection, not a mid-transaction rollback. No fault injection, account activation, SQL writes, deployment, cleanup deletion or implicit fixture creation.', + stage: 'TWO_FRESH_CUSTOMERS_ONE_REVIEWED_DISPATCH_LOADING_NOT_PICKED', + fixture: 'marker, warehouseId, dispatchNo, tripNo, tripProof, pdaToken, optional current-run loadingMeasurements; members[2]: marker/customerId/incomingNo/shared dispatchNo/dispatchItemId/balanceId/positionId/palletId/palletNo/receiptVerified', + preparation: 'Two independently qualified customers formally received one pallet/box each in the same warehouse. One native Create with both actual source selections; automatic unique Fleet record, resources and both native reviews. No picking or delivery arrangement.', + reader: 'readInventory(scope) returns all raw balances/positions/immutable movements including zero, exact marker/customer/warehouse and CUSTOMER_WAREHOUSE_COMPLETE coverage.', + boundary: 'Prerequisite rejection is separate from an explicitly injected real mid-transaction rollback. Actual loading needs actual evidence; absent measurement fields block. No environment loader, SQL or implicit account lifecycle.', }) -/** Import/default call is network-free. Explicit execute:true requires separately authorized TEST execution. - * Tokens stay in the caller, never in the persisted proof. Failure retains fixture and exact request keys. - */ export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory, verifyDepartureRollback } = {}) { if (!execute) return fixtureContract if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== fixtureContract.stage - || !fixture.tripNo || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function' - || fixture.members?.length !== 2 || new Set(fixture.members.map(row=>row.customerId)).size !== 2 - || fixture.members.some(row=>row.marker!==context.marker || row.receiptVerified!==true - || ['customerId','incomingNo','dispatchNo','outboundNo','deliveryNo','balanceId','positionId','palletId','palletNo'].some(key=>!id(row[key])))) { - throw new Error('Requires two distinct current-run customers, immutable receipt references and a complete raw inventory reader') - } - for (const key of ['dispatchNo','outboundNo','deliveryNo','positionId','palletId']) { - if (new Set(fixture.members.map(row=>id(row[key]))).size!==2) throw new Error(`Cross-customer fixture reuses ${key}`) - } - context.createdResources ||= {} + || !fixture.dispatchNo || !fixture.tripNo || fixture.tripProof?.dispatchNo !== fixture.dispatchNo || fixture.tripProof.tripNo !== fixture.tripNo + || !context.createdResources?.fleetTrips?.includes(fixture.tripProof) + || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function' || fixture.members?.length !== 2 + || new Set(fixture.members.map(row => row.customerId)).size !== 2 + || fixture.members.some(row => row.marker !== context.marker || row.receiptVerified !== true || row.dispatchNo !== fixture.dispatchNo + || ['customerId', 'incomingNo', 'dispatchItemId', 'balanceId', 'positionId', 'palletId', 'palletNo'].some(field => !id(row[field])))) + throw new Error('Requires two distinct current-run customers and one reviewed native dispatch with complete raw inventory evidence') + for (const field of ['incomingNo', 'dispatchItemId', 'positionId', 'palletId', 'balanceId']) + if (new Set(fixture.members.map(row => id(row[field]))).size !== 2) throw new Error(`Cross-customer fixture reuses ${field}`) + context.createdResources ??= {} if (context.createdResources.fleetCrossCustomer) throw new Error('Proof already exists; retain failed run and reconcile explicitly') - const proof = context.createdResources.fleetCrossCustomer = { marker:context.marker,tripNo:fixture.tripNo, - warehouseId:fixture.warehouseId,members:fixture.members.map(({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})=>({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})), - status:'CHECKING',requests:[],snapshots:{},gateProof:'NOT_RUN',midTransactionRollback:'NOT_TESTED',cleanup:'Caller owns account/session lifecycle; retain all business fixtures' } - const check = (name, passed) => { - context.assert(MODULE,name,null,Boolean(passed),'',OPTIONS) - if (!passed) throw new Error(name) - } + const proof = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo, tripNo: fixture.tripNo, + warehouseId: fixture.warehouseId, members: fixture.members.map(({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo }) => + ({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo })), + status: 'CHECKING', requests: [], snapshots: {}, gateProof: 'NOT_RUN', midTransactionRollback: 'NOT_TESTED', cleanup: 'Caller owns account/session lifecycle; retain all business fixtures' } + const check = (name, passed) => { context.assert(MODULE, name, null, Boolean(passed), '', OPTIONS); if (!passed) throw new Error(name) } + const equal = (a, b, name) => check(name, canonical(a) === canonical(b)) const key = suffix => `${context.marker}-CROSS-${suffix}` + const path = `${ROOT}/dispatches/${encode(fixture.dispatchNo)}`, pdaPath = `${PDA}/${encode(fixture.dispatchNo)}` const tripPath = `${ROOT}/fleet/trips/${encode(fixture.tripNo)}` - const request = async (method,path,body,label,pda=false,rejection=false) => { - const response = await context.request(method,path,body,pda?fixture.pdaToken:context.token,{}) - proof.requests.push({method,path,body,label,http:response?.http,code:response?.body?.code,requestId:response?.requestId,traceId:response?.traceId}) - const denied = response?.http===409 || (response?.http===200 && id(response.body?.code)==='A0409') - check(label,Boolean(response?.requestId && response?.traceId) && (rejection?denied && rejection(id(response.body?.message)):ok(response))) + const request = async (method, target, body, label, pda = false, rejection) => { + const entry = { method, path: target, body, label, status: 'SENDING' }; proof.requests.push(entry); await context.checkpoint?.() + let response + try { response = await context.request(method, target, body, pda ? fixture.pdaToken : context.token, {}) } + catch (error) { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint?.(); throw error } + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) + await context.checkpoint?.() + const denied = response?.http === 409 || (response?.http === 200 && id(response.body?.code) === 'A0409') + check(label, Boolean(response?.requestId && response?.traceId) && (rejection ? denied && rejection(id(response.body?.message)) : ok(response))) return response.body?.data } const stock = async member => { @@ -81,120 +79,152 @@ export async function verifyFleetCrossCustomer(context, fixture, { execute = fal return raw } const snapshot = async label => { - const trip=await request('GET',tripPath,undefined,'Read shared trip') - check('Trip members are exactly the two independent customers',trip.trip?.tripNo===fixture.tripNo && id(trip.trip.warehouseId)===id(fixture.warehouseId) - && trip.dispatches?.length===2 && fixture.members.every(member=>trip.dispatches.some(row=>row.dispatchNo===member.dispatchNo && id(row.customerId)===id(member.customerId)))) - const members=[] - for(const member of fixture.members) { - const dispatch=await request('GET',`${ROOT}/dispatches/${encode(member.dispatchNo)}`,undefined,'Read customer dispatch') - const outbound=await request('GET',scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`,member.customerId),undefined,'Read customer outbound') - const delivery=await request('GET',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId),undefined,'Read customer delivery') - const task=await request('GET',`${PDA}/${encode(member.dispatchNo)}`,undefined,'Read PDA task',true) - check('Execution records retain exact customer, warehouse and dispatch ownership',dispatch.dispatchNo===member.dispatchNo - && id(dispatch.overseasWarehouseId)===id(fixture.warehouseId) && dispatch.items?.length===1 - && id(dispatch.items[0].warehousePalletId)===id(member.palletId) - && outbound.outbound?.outboundNo===member.outboundNo && outbound.outbound.dispatchNo===member.dispatchNo - && id(outbound.outbound.overseasWarehouseId)===id(fixture.warehouseId) && outbound.deliveries?.length===1 - && outbound.deliveries[0].deliveryNo===member.deliveryNo && id(outbound.deliveries[0].customerId)===id(member.customerId) - && delivery.order?.deliveryNo===member.deliveryNo && id(delivery.order.customerId)===id(member.customerId) - && delivery.order.dispatchNo===member.dispatchNo && delivery.order.outboundNo===member.outboundNo - && task.dispatchNo===member.dispatchNo && id(task.warehouseId)===id(fixture.warehouseId) && task.tripNo===fixture.tripNo) - members.push({dispatch,outbound,delivery,task,stock:await stock(member)}) + const trip = await request('GET', tripPath, undefined, 'Read automatic unique Fleet resource record') + check('One trip binds exactly this one native cross-customer dispatch', trip.trip?.tripNo === fixture.tripNo + && id(trip.trip.warehouseId) === id(fixture.warehouseId) && trip.dispatches?.length === 1 && trip.dispatches[0].dispatchNo === fixture.dispatchNo) + const dispatch = await request('GET', path, undefined, 'Read native whole dispatch') + const items = await request('GET', `${path}/items`, undefined, 'Read actual per-customer cargo') + const task = await request('GET', pdaPath, undefined, 'Read bound warehouse native PDA task', true) + check('Native main/items/PDA retain both true customers and physical pallets', dispatch.dispatchNo === fixture.dispatchNo + && items.dispatchNo === fixture.dispatchNo && task.dispatchNo === fixture.dispatchNo && id(task.warehouseId) === id(fixture.warehouseId) + && id(dispatch.recordVersion) === id(items.recordVersion) && id(task.recordVersion) === id(dispatch.recordVersion) + && items.items?.length === 2 && task.items?.length === 2 && fixture.members.every(member => + items.items.some(i => id(i.dispatchItemId) === member.dispatchItemId && id(i.customerId) === member.customerId + && id(i.warehousePalletId) === member.palletId && i.palletNo === member.palletNo && integer(i.scheduledPackages) === 1) + && task.items.some(i => id(i.dispatchItemId) === member.dispatchItemId && id(i.customerId) === member.customerId && i.palletNo === member.palletNo))) + const members = [] + for (const member of proof.members) { + let outbound = null, delivery = null + if (member.outboundNo || member.deliveryNo) { + check('Customer execution documents are both explicit', member.outboundNo && member.deliveryNo) + outbound = await request('GET', scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`, member.customerId), undefined, 'Read customer outbound') + delivery = await request('GET', scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`, member.customerId), undefined, 'Read customer delivery') + check('Execution documents keep customer, warehouse and shared main ownership', outbound.outbound?.outboundNo === member.outboundNo + && outbound.outbound.dispatchNo === fixture.dispatchNo && id(outbound.outbound.overseasWarehouseId) === id(fixture.warehouseId) + && outbound.deliveries?.length === 1 && outbound.deliveries[0].deliveryNo === member.deliveryNo + && id(outbound.deliveries[0].customerId) === member.customerId && delivery.order?.deliveryNo === member.deliveryNo + && id(delivery.order.customerId) === member.customerId && delivery.order.dispatchNo === fixture.dispatchNo && delivery.order.outboundNo === member.outboundNo) + } + members.push({ outbound, delivery, stock: await stock(member) }) } - const value={trip,members};proof.snapshots[label]=value;return value + const value = { trip, dispatch, items, task, members }; proof.snapshots[label] = value; return value } - const equal = (a,b,name)=>check(name,canonical(a)===canonical(b)) - const pick = async (member,index) => { - const before=await snapshot(`BEFORE_PICK_${index}`),task=before.members[index].task - check('PDA exposes the single real current-run pallet and position',task.items?.length===1 && integer(task.items[0].scheduledPackages)===1 - && integer(task.items[0].pickedPackages)===0 && task.items[0].palletNo===member.palletNo && task.items[0].positions?.length===1 - && id(task.items[0].positions[0].id)===id(member.positionId) && integer(task.items[0].positions[0].availablePackages)===1) - const body={positionId:member.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker} - const path=`${PDA}/${encode(member.dispatchNo)}/items/${encode(task.items[0].dispatchItemId)}/pick` - await request('POST',path,body,'Pick this customer only',true) - await request('POST',`${PDA}/${encode(member.dispatchNo)}/complete`,{idempotencyKey:key(`COMPLETE-${index}`)},'Complete this customer picking',true) - const after=await snapshot(`AFTER_PICK_${index}`) - equal(before.members[1-index].stock,after.members[1-index].stock,'Other customer inventory unchanged by picking') - const expected=structuredClone(before.members[index].stock) - for(const collection of ['balances','positions']) { - const row=expected[collection].find(row=>id(row.id)===id(collection==='balances'?member.balanceId:member.positionId)) - row.availablePackages=integer(row.availablePackages)-1 - } - const counts=raw=>Object.fromEntries(['balances','positions'].map(collection=>[collection,raw[collection].map(row=>({id:id(row.id),...Object.fromEntries((collection==='balances'?balanceBuckets:buckets).map(field=>[field,integer(row[field])]))}))])) - equal(counts(expected),counts(after.members[index].stock),'Only selected customer position and balance lose one box') - const old=before.members[index].stock.ledger,newRows=after.members[index].stock.ledger.filter(row=>!old.some(prior=>id(prior.id)===id(row.id))) - equal(old,after.members[index].stock.ledger.filter(row=>old.some(prior=>id(prior.id)===id(row.id))),'Immutable old movements preserved') - check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity',newRows.length===3 && ['ALLOCATE','PICK','SHIP'].every(type=>newRows.filter(row=>row.businessType===type - && id(row.positionId)===id(member.positionId) && row.businessOrderNo===member.dispatchNo && Number(row.deltaPackages)===(type==='SHIP'?-1:1)).length===1)) - await request('POST',path,body,'Replay exact customer pick',true) - equal(after,await snapshot(`PICK_REPLAY_${index}`),'Pick replay changes neither customer nor any execution record') + const pick = async (member, index) => { + const before = await snapshot(`BEFORE_PICK_${index}`), item = before.task.items.find(i => id(i.dispatchItemId) === member.dispatchItemId) + check('PDA exposes exactly this customer real current-run pallet and position', integer(item.scheduledPackages) === 1 && integer(item.pickedPackages) === 0 + && item.palletNo === member.palletNo && item.positions?.length === 1 && id(item.positions[0].positionId) === member.positionId + && integer(item.positions[0].availablePackages) === 1) + const body = { recordVersion: before.task.recordVersion, positionId: member.positionId, packages: 1, productQuantity: 1, + idempotencyKey: key(`PICK-${index}`), remark: context.marker } + const pickPath = `${pdaPath}/items/${encode(member.dispatchItemId)}/pick` + await request('POST', pickPath, body, 'Pick this customer only', true) + const after = await snapshot(`AFTER_PICK_${index}`) + equal(before.members[1-index].stock, after.members[1-index].stock, 'Other customer inventory unchanged by picking') + const expected = structuredClone(before.members[index].stock) + for (const collection of ['balances', 'positions']) expected[collection].find(row => id(row.id) === (collection === 'balances' ? member.balanceId : member.positionId)).availablePackages = 0 + const counts = raw => Object.fromEntries(['balances','positions'].map(collection => [collection, raw[collection].map(row => ({ id: id(row.id), + ...Object.fromEntries((collection === 'balances' ? balanceBuckets : buckets).map(field => [field, integer(row[field])])) }))])) + equal(counts(expected), counts(after.members[index].stock), 'Only selected customer position and balance lose one box') + const old = before.members[index].stock.ledger, newRows = after.members[index].stock.ledger.filter(row => !old.some(prior => id(prior.id) === id(row.id))) + equal(old, after.members[index].stock.ledger.filter(row => old.some(prior => id(prior.id) === id(row.id))), 'Immutable old movements preserved') + check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity', newRows.length === 3 && ['ALLOCATE','PICK','SHIP'].every(type => newRows.filter(row => + row.businessType === type && id(row.positionId) === member.positionId && row.businessOrderNo === fixture.dispatchNo && Number(row.deltaPackages) === (type === 'SHIP' ? -1 : 1)).length === 1)) + await request('POST', pickPath, body, 'Replay exact customer pick', true) + equal(after, await snapshot(`PICK_REPLAY_${index}`), 'Pick replay changes neither customer nor any execution record') } try { - let initial=await snapshot('INITIAL') - check('Minimal fresh fixture has one available box per customer and no prior execution',initial.trip.trip.status==='PLANNED' - && initial.members.every((row,index)=>row.dispatch.status==='PENDING_DEPARTURE' && integer(row.dispatch.packageCount)===1 - && integer(row.dispatch.pickedPackageCount)===0 && row.outbound.outbound.status==='WAIT_OUTBOUND' && row.delivery.order.status==='WAIT_DELIVERY' - && integer(row.stock.positions.find(p=>id(p.id)===id(fixture.members[index].positionId)).availablePackages)===1 - && row.stock.balances.every(balance=>balanceBuckets.filter(field=>field!=='availablePackages').every(field=>integer(balance[field])===0)) - && row.stock.balances.reduce((sum,balance)=>sum+integer(balance.availablePackages),0)===1)) - let detail=initial.trip - for(const member of fixture.members)detail=await request('POST',`${tripPath}/dispatches/${encode(member.dispatchNo)}/review`, - {recordVersion:detail.trip.recordVersion,decision:'APPROVED'},'Review each customer dispatch separately') - check('Both customer dispatches approved',detail.dispatches.every(row=>row.review?.status==='APPROVED')) - await pick(fixture.members[0],0) - const rejectedBefore=await snapshot('GATE_BEFORE') - check('Precisely one customer is ready',rejectedBefore.members[0].dispatch.status==='READY_TO_DEPART' && integer(rejectedBefore.members[1].dispatch.pickedPackageCount)===0) - await request('POST',`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart`, - {recordVersion:rejectedBefore.trip.trip.recordVersion,idempotencyKey:key('UNREADY-DEPART'),remark:context.marker},'Unready second customer blocks whole trip before execution',true, - message=>message.includes(fixture.members[1].dispatchNo) && message.includes('请完成拣货并核平全部箱数')) - equal(rejectedBefore,await snapshot('GATE_AFTER'),'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged') - proof.gateProof='PREREQUISITE_REJECTION_VERIFIED' - await pick(fixture.members[1],1) - const loading=await request('GET',`${tripPath}/loading`,undefined,'Read ready shared loading') - check('Both real pallets ready for loading',!loading.blockReason && loading.cargo?.length===2) - detail=await request('GET',tripPath,undefined,'Read current trip route') - const measured=controlledPalletMeasurements(loading.cargo,detail.trip.stops) - const loaded=await request('POST',`${tripPath}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:measured,reason:context.marker},'Confirm controlled TEST measurements') - const reviewed=await request('POST',`${tripPath}/loading-review`,{recordVersion:loaded.recordVersion,decision:'APPROVED'},'Approve whole-trip loading') - const ready=await snapshot('READY') - const departBody={recordVersion:reviewed.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker} - const departPath=`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart` - if(verifyDepartureRollback!==undefined) { - check('Explicit rollback verifier is callable',typeof verifyDepartureRollback==='function') - const rollback=await verifyDepartureRollback({context,fixture,ready,departPath,departBody,snapshot}) - check('Mid-transaction rollback has exact owned verification',rollback?.verified===true && rollback.scope==='OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') - equal(ready,await snapshot('ROLLBACK_RESTORED'),'Mid-transaction rollback restores both complete customer execution and inventory snapshots') - proof.rollbackEvidence=rollback;proof.midTransactionRollback='VERIFIED' + const initial = await snapshot('INITIAL') + check('Fresh native fixture is reviewed LOADING with both customer receipts untouched', initial.trip.trip.status === 'PLANNED' + && initial.dispatch.status === 'ACTIVE' && initial.dispatch.stage === 'LOADING' && initial.dispatch.reviewState === 'APPROVED' + && initial.dispatch.reviews?.dispatchReview?.status === 'APPROVED' && initial.dispatch.reviews?.loadingReview?.status === 'APPROVED' + && integer(initial.dispatch.counts.packageCount) === 2 && integer(initial.dispatch.counts.pickedPackageCount) === 0 + && initial.dispatch.documents?.deliveries?.length === 0 && initial.members.every((row,index) => + integer(row.stock.positions.find(p => id(p.id) === fixture.members[index].positionId).availablePackages) === 1 + && row.stock.balances.every(balance => balanceBuckets.filter(field => field !== 'availablePackages').every(field => integer(balance[field]) === 0)) + && row.stock.balances.reduce((sum,balance) => sum + integer(balance.availablePackages), 0) === 1)) + await pick(proof.members[0], 0) + const rejectedBefore = await snapshot('GATE_BEFORE') + check('Precisely one customer picked while main remains LOADING', integer(rejectedBefore.dispatch.counts.pickedPackageCount) === 1 + && rejectedBefore.dispatch.stage === 'LOADING' && rejectedBefore.task.pickingReconciled === false) + const preview = await request('GET', `${pdaPath}/loading`, undefined, 'Read incomplete native loading manifest', true) + await request('POST', `${pdaPath}/loading-complete`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-LOAD'), + manifestHash: preview.manifestHash, measurements: [], remark: context.marker }, 'Unready second customer blocks explicit loading', true, + message => /拣货.*核平|尚未核平/.test(message)) + await request('POST', `${pdaPath}/depart`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-DEPART'), remark: context.marker }, + 'Unready second customer blocks whole trip before execution', true, message => /装车|派送/.test(message)) + equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged') + proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED' + await pick(proof.members[1], 1) + await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements) + const arrangement = await request('GET', `${path}/delivery-arrangement`, undefined, 'Read native loaded per-customer delivery arrangement') + check('Loaded arrangement groups cover exactly both real customers', arrangement.dispatchNo === fixture.dispatchNo && arrangement.status === 'ACTIVE' + && arrangement.stage === 'AFTER_LOADING' && arrangement.arrange?.allowed === true && arrangement.cargoHash && arrangement.loadingCompletionHash + && arrangement.groups?.length === 2 && proof.members.every(m => arrangement.groups.some(g => id(g.customerId) === m.customerId && g.cargo?.length + && g.cargo.every(c => id(c.customerId) === m.customerId && id(c.warehousePalletId) === m.palletId) && g.blockReasons?.length === 0 + && g.destination?.key === proof.snapshots.AFTER_PICK_1.items.items.find(i => id(i.customerId) === m.customerId)?.destination?.key))) + const arrangeBody = { recordVersion: arrangement.recordVersion, idempotencyKey: key('ARRANGE'), cargoHash: arrangement.cargoHash, + loadingCompletionHash: arrangement.loadingCompletionHash, groups: arrangement.groups.map(g => ({ groupKey: g.groupKey, groupCargoHash: g.groupCargoHash, + plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString(), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', + loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本轮实际装载', appointmentAccount: 'SELF', remark: `${context.marker} TEST合成资料`, + externalFreightHash: g.externalFreightHash, feeCoverageCargoHash: g.feeCoverageCargoHash, feeCoverage: [], + fees: (g.requiredServices || []).map(feeType => ({ feeType, feeName: 'TEST本轮派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 })) })) } + proof.arrangeRequest = arrangeBody + const arranged = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Arrange both customers after real loading before departure') + check('Native arrangement creates customer-isolated outbounds and deliveries', arranged.dispatchNo === fixture.dispatchNo && arranged.stage === 'AFTER_LOADING' + && arranged.result?.deliveries?.length === 2 && arranged.result.outbounds?.length === 2 + && new Set(arranged.result.deliveries.map(d => d.deliveryNo)).size === 2 && new Set(arranged.result.outbounds.map(o => o.outboundNo)).size === 2) + for (const [index, member] of proof.members.entries()) { + const deliveries = arranged.result.deliveries.filter(d => id(d.customerId) === member.customerId) + check('Each customer has exactly its own source-specific delivery and outbound', deliveries.length === 1 && integer(deliveries[0].packageCount) === 1 + && arranged.result.outbounds.some(o => id(o.customerId) === member.customerId && o.outboundNo === deliveries[0].outboundNo && integer(o.packageCount) === 1)) + Object.assign(member, { outboundNo: deliveries[0].outboundNo, deliveryNo: deliveries[0].deliveryNo }) + Object.assign(fixture.members[index], { outboundNo: member.outboundNo, deliveryNo: member.deliveryNo }) } - const departed=await request('POST',departPath,departBody,'Depart whole trip once',true) - check('Departure includes both customer dispatches',departed.tripNo===fixture.tripNo && departed.status==='IN_TRANSIT' && integer(departed.shippedPackages)===2 - && canonical(departed.dispatches.map(row=>row.dispatchNo).sort())===canonical(fixture.members.map(row=>row.dispatchNo).sort())) - const after=await snapshot('DEPARTED') - check('Both outbounds completed and trip in transit',after.trip.trip.status==='IN_TRANSIT' && after.members.every(row=>row.dispatch.status==='DEPARTED' && row.outbound.outbound.status==='OUTBOUNDED')) - equal(ready.members.map(row=>row.stock),after.members.map(row=>row.stock),'Departure does not deduct either inventory again') - const replay=await request('POST',departPath,departBody,'Replay exact shared departure',true) - check('Shared departure replay is explicit',replay.replayed===true) - equal(after,await snapshot('DEPART_REPLAY'),'Departure replay preserves both complete inventories and execution facts') - for(const [index,member] of fixture.members.entries()) { - const before=await snapshot(`BEFORE_SIGN_${index}`),path=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId) - await request('PUT',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/status`,member.customerId), - {status:'DELIVERING',expectedVersion:before.members[index].delivery.order.version,remark:context.marker},'Start only this customer delivery') - const documentPath=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`,member.customerId) - const docs=await request('GET',documentPath,undefined,'Read this delivery document version') - const uploaded=await context.requestMultipart('POST',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId), - {name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${member.deliveryNo}`)}) - proof.requests.push({method:'POST',path:scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),label:'Upload own synthetic receipt',http:uploaded?.http,code:uploaded?.body?.code,requestId:uploaded?.requestId,traceId:uploaded?.traceId}) - check('Receipt uploaded through correct delivery endpoint',ok(uploaded) && id(uploaded.body?.data?.fileId)) - const documents=(docs.categories||[]).filter(row=>row.category!=='RECEIPT').map(row=>({category:row.category,fileIds:(row.files||[]).map(file=>file.fileId)})) - documents.push({category:'RECEIPT',fileIds:[id(uploaded.body.data.fileId)]}) - await request('PUT',documentPath,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents},'Attach receipt to independently complete this delivery') - const signed=await snapshot(`SIGNED_${index}`) - check('This customer independently signed',signed.members[index].delivery.order.status==='DELIVERED') - equal(before.members[1-index].delivery,signed.members[1-index].delivery,'Other customer delivery is not signed or changed implicitly') - equal(before.members.map(row=>row.stock),signed.members.map(row=>row.stock),'Signing does not mutate either customer inventory') - // Retain the independently readable result as evidence, not a fabricated completion command. - await request('GET',path,undefined,'Read signed customer delivery') + const arrangedReplay = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Replay exact native customer arrangement') + check('Native arrangement replay keeps original documents and main version', arrangedReplay.replayed === true && id(arrangedReplay.recordVersion) === id(arranged.recordVersion) + && canonical(arrangedReplay.result) === canonical(arranged.result)) + const ready = await snapshot('READY'), departBody = { recordVersion: ready.task.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } + const departPath = `${pdaPath}/depart` + if (verifyDepartureRollback !== undefined) { + check('Explicit rollback verifier is callable', typeof verifyDepartureRollback === 'function') + const rollback = await verifyDepartureRollback({ context, fixture, ready, departPath, departBody, snapshot }) + check('Mid-transaction rollback has exact owned verification', rollback?.verified === true && rollback.scope === 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') + equal(ready, await snapshot('ROLLBACK_RESTORED'), 'Mid-transaction rollback restores both complete customer execution and inventory snapshots') + proof.rollbackEvidence = rollback; proof.midTransactionRollback = 'VERIFIED' } - proof.status='VERIFIED';return proof - } catch(error) {proof.status='FAILED';proof.error=error.message;throw error} + const departed = await request('POST', departPath, departBody, 'Depart one cross-customer native dispatch once', true) + check('Departure includes both customers in the one actual native main', departed.dispatchNo === fixture.dispatchNo && departed.status === 'ACTIVE' + && departed.stage === 'DELIVERING' && integer(departed.result?.shippedPackages) === 2 && departed.result.deliveries?.length === 2) + proof.departure = departed + const after = await snapshot('DEPARTED') + check('Both customer outbounds completed and main delivering', after.trip.trip.status === 'IN_TRANSIT' && after.dispatch.stage === 'DELIVERING' + && after.members.every(row => row.outbound.outbound.status === 'OUTBOUNDED' && row.delivery.order.status === 'DELIVERING')) + equal(ready.members.map(row => row.stock), after.members.map(row => row.stock), 'Departure does not deduct either inventory again') + const replay = await request('POST', `${path}/depart`, departBody, 'Platform exact replay of PDA native departure') + check('Shared departure replay is explicit and returns original result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion) + && canonical(replay.result) === canonical(departed.result)) + equal(after, await snapshot('DEPART_REPLAY'), 'Departure replay preserves both complete inventories and execution facts') + for (const [index, member] of proof.members.entries()) { + const before = await snapshot(`BEFORE_SIGN_${index}`), documentPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`, member.customerId) + const docs = await request('GET', documentPath, undefined, 'Read this customer delivery document version') + const uploadPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`, member.customerId) + const uploadEntry = { method: 'POST', path: uploadPath, label: 'Upload own synthetic receipt', status: 'SENDING' }; proof.requests.push(uploadEntry); await context.checkpoint?.() + let uploaded + try { uploaded = await context.requestMultipart('POST', uploadPath, { name: `${context.marker}-${index}-TEST-receipt.txt`, type: 'text/plain', + content: new TextEncoder().encode(`${context.marker} synthetic TEST receipt ${member.deliveryNo}`) }) } + catch (error) { uploadEntry.status = 'UNKNOWN_RESULT'; await context.checkpoint?.(); throw error } + Object.assign(uploadEntry, { status: 'RESPONDED', http: uploaded?.http, code: uploaded?.body?.code, requestId: uploaded?.requestId, traceId: uploaded?.traceId }); await context.checkpoint?.() + check('Receipt uploaded through correct customer endpoint', ok(uploaded) && uploaded.requestId && uploaded.traceId && id(uploaded.body?.data?.fileId)) + const documents = (docs.categories || []).filter(row => row.category !== 'RECEIPT').map(row => ({ category: row.category, fileIds: (row.files || []).map(file => file.fileId) })) + documents.push({ category: 'RECEIPT', fileIds: [id(uploaded.body.data.fileId)] }) + await request('PUT', documentPath, { expectedVersion: docs.version, idempotencyKey: key(`SIGN-${index}`), bolNo: docs.bolNo || null, documents }, 'Attach receipt to independently complete this delivery') + const signed = await snapshot(`SIGNED_${index}`) + check('This customer independently signed', signed.members[index].delivery.order.status === 'DELIVERED') + equal(before.members[1-index].delivery, signed.members[1-index].delivery, 'Other customer delivery is not signed or changed implicitly') + equal(before.members.map(row => row.stock), signed.members.map(row => row.stock), 'Signing does not mutate either customer inventory') + } + const final = await snapshot('FINAL') + check('All normal customer signatures aggregate native delivery completion', final.dispatch.status === 'ACTIVE' && final.dispatch.stage === 'DELIVERED') + proof.status = 'VERIFIED'; return proof + } catch (error) { proof.status = 'FAILED'; proof.error = error.message; throw error } } diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs index 6ad5a84cf..de74c0f17 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs @@ -1,10 +1,10 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { createFleetCrossCustomerFixture, preparationContract } from './fleet-cross-customer-fixture.mjs' +import { createFleetCrossCustomerFixture, preparationContract, safeFixtureCount } from './fleet-cross-customer-fixture.mjs' import { fixtureContract } from './fleet-cross-customer-acceptance.mjs' // Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too. -function model(fault = '') { +function model(fault = '', countPatch = {}) { const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = [] const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' } const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30, @@ -23,11 +23,10 @@ function model(fault = '') { ledger: [{ ...scope, id: `L${n}`, balanceId: `B${n}`, positionId: `P${n}`, palletNo: `PALLET${n}`, businessType: 'STOCK_IN', businessOrderNo: transfer.transferNo, beforePackages: '0', deltaPackages: '1', afterPackages: '1' }] } } - const cargo = () => trip.dispatches.map(binding => { - const d = dispatches.find(row => row.dispatchNo === binding.dispatchNo), n = dispatches.indexOf(d) - return { dispatchNo: d.dispatchNo, customerId: binding.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, - destinationKey: 'FBA:FBA', destinationLabel: 'FBA', packages: 1 } - }) + let native + const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId, + warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0, + destination: { key: 'FBA:FBA' } })) const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' } const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture }, createdResources: { merchantQualificationFixture: { marker: 'PARENT' } }, @@ -58,7 +57,7 @@ function model(fault = '') { customers.push(customer) return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password }) } - if (path.endsWith('/submit-review')) return response({ status: 'PENDING' }) + if (path.endsWith('/submit-review') && path.includes('/iam/')) return response({ status: 'PENDING' }) if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id }))) if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' }) if (path.endsWith('/approve')) return response({}) @@ -108,43 +107,44 @@ function model(fault = '') { if (path.endsWith('/dispatches/candidates')) { const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n] assert.equal(transfer.status, 'READY_FOR_DISPATCH') - return response([{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo, - warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, planPalletId: `PLAN${n}`, sourceStage: 'RECEIVED', packageCount: 1 }]) + return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, + warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' }, + sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] }) } - if (path.endsWith('/drafts/containers')) return response({ id: `CONTAINER${dispatches.length}` }) - if (path.endsWith('/draft-locks/lock')) return response(null) - if (path.endsWith('/pre-arrange')) { - const n = Number(body.planPalletIds[0].slice(-1)) - const dispatch = { dispatchNo: `DS${n}`, overseasWarehouseId: '10', status: 'PENDING_DEPARTURE', items: [{ warehousePalletId: `PAL${n}` }], - packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0 } - dispatches.push(dispatch); return response(dispatch) + if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { + assert.equal(body.cargo.length, 2); assert.equal(body.warehouseId, '10') + assert.deepEqual(body.cargo.map(row => row.planPalletId), ['PLAN0', 'PLAN1']) + assert.ok(body.cargo.every(row => !Object.hasOwn(row, 'customerId'))) + native = { dispatchNo: 'DS-ONE', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: 0, + counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {}, + documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} } + trip.dispatches = [{ dispatchNo: native.dispatchNo }] + Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] }) + dispatches.push(native); return response(native) } - if (path.endsWith('/master-data/vehicles')) return response({ id: 'VEHICLE' }) + if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) } if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' }) if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' }) - if (path.endsWith('/fleet/trips')) { Object.assign(trip.trip, body); return response(trip) } - if (path.includes('/fleet/trips/TRIP')) { - if (path.endsWith('/loading')) return response({ recordVersion: trip.trip.recordVersion, cargo: fault === 'missing second cargo' ? cargo().slice(0, 1) : cargo() }) - if (method !== 'GET') { - assert.equal(body.recordVersion, trip.trip.recordVersion) - trip.trip.recordVersion = String(Number(trip.trip.recordVersion) + 1) + if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 }) + if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) + if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({ dispatchNo: native.dispatchNo, recordVersion: native.recordVersion, + items: fault === 'missing second cargo' ? cargo().slice(0,1) : cargo() }) + if (path.startsWith('/api/platform/v1/last-leg/dispatches/DS-ONE')) { + if (method === 'PUT') { + assert.equal(body.recordVersion, native.recordVersion) + Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId }) + native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } } + native.recordVersion++ + } else if (method === 'POST') { + assert.equal(body.recordVersion, native.recordVersion); native.recordVersion++ + if (path.endsWith('/submit-review')) native.reviewState = 'DISPATCH_PENDING' + if (path.endsWith('/dispatch-review')) { native.reviewState = 'LOADING_PENDING'; native.reviews.dispatchReview = { status: 'APPROVED' } } + if (path.endsWith('/loading-review')) { native.reviewState = 'APPROVED'; native.reviews.loadingReview = { status: 'APPROVED' }; native.stage = 'LOADING' } } - if (path.endsWith('/assignment')) Object.assign(trip.trip, { vehicleId: body.vehicleId, driverId: body.driverId }) - else if (path.endsWith('/dispatches/bind')) trip.dispatches.push({ dispatchNo: body.dispatchNo, customerId: transfers[Number(body.dispatchNo.slice(-1))].customerId, review: { status: 'PENDING' } }) - else if (path.endsWith('/plan')) trip.trip.stops = body.stops - else if (path.endsWith('/review')) trip.dispatches.find(row => path.includes(`/${row.dispatchNo}/`)).review.status = 'APPROVED' - return response(trip) + const detail = copy(native) + if (fault === 'foreign delivery') detail.documents.deliveries = [{ customerId: 'OTHER' }] + return response(detail) } - const n = Number(path.match(/(?:DS|OUT|DL)([01])/)[1]), dispatch = dispatches[n] - const order = { customerId: transfers[n].customerId, deliveryNo: `DL${n}`, outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, status: 'WAIT_DELIVERY' } - if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: true, quotes: [] }) - if (path.endsWith('/transfer-delivery')) { - assert.equal(body.fees.length, 1); assert.equal(body.fees[0].amount, 30) - return response({ dispatchNo: dispatch.dispatchNo, outboundNo: `OUT${n}`, deliveries: [order], packageCount: 1 }) - } - if (path.includes('/delivery-orders/')) { assert.equal(url.searchParams.get('customerId'), transfers[n].customerId); return response({ order: fault === 'foreign delivery' ? { ...order, customerId: 'OTHER' } : order }) } - if (path.includes('/outbound-orders/')) return response({ outbound: { outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, overseasWarehouseId: '10', status: 'WAIT_OUTBOUND' }, deliveries: [order] }) - if (path.endsWith(`/dispatches/${dispatch.dispatchNo}`)) return response(dispatch) throw new Error(`Unexpected offline request ${method} ${path}`) }, } @@ -180,9 +180,9 @@ test('real helpers build independent current-run customers, receipts and one com assert.notEqual(m.customers[0].marker, m.customers[1].marker) const proof = m.context.createdResources.fleetCrossCustomerFixture assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT']) - assert.equal(m.trip.dispatches.length, 2); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA']) - assert.equal(m.calls.filter(row => row.raw.endsWith('/plan')).length, 2) - assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 2) + assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA']) + assert.equal(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/platform/v1/last-leg/dispatches').length, 1) + assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 0) assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw))) const persisted = JSON.stringify(m.context.createdResources) for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret)) @@ -211,3 +211,31 @@ test('missing explicit warehouse or caller PDA fails before reading or writing A assert.equal(m.calls.length, 0) } }) + + +test('native Jackson Long count strings build the same complete one-main fixture', async () => { + const m = model('', { packageCount: '2', pickedPackageCount: '0', shippedPackageCount: '0' }) + const fixture = await createFleetCrossCustomerFixture(m.context, m.options) + assert.equal(fixture.dispatchNo, 'DS-ONE') + assert.equal(fixture.tripNo, 'TRIP') + assert.equal(fixture.members.length, 2) + assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo)) +}) + +test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => { + for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) { + assert.equal(safeFixtureCount(value), expected) + } + for (const value of [null, undefined, true, false, '', ' ', ' 2 ', '02', '+2', '-0', '-1', -1, 1.5, '1.5', '1e2', '2E0', NaN, Infinity, {}, [], Number.MAX_SAFE_INTEGER + 1, '9007199254740992']) { + assert.throws(() => safeFixtureCount(value), /非负安全整数/) + } +}) + +test('invalid created main counts fail before resource preparation or picking', async () => { + for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) { + const m = model('', { [field]: field === 'packageCount' ? '2e0' : false }) + await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /非负安全整数/) + assert.equal(m.calls.some(row => row.raw.endsWith('/master-data/vehicles')), false) + assert.equal(m.calls.some(row => row.raw.endsWith('/pick')), false) + } +}) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs index 0a345ce51..fea8b2930 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs @@ -8,6 +8,11 @@ const ROOT = '/api/platform/v1/last-leg' const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers' const OPTIONS = { scenario: MODULE, deploymentBlocker: true } const id = value => value == null ? '' : String(value) +export function safeFixtureCount(value) { + if (!['number', 'string'].includes(typeof value) || !/^(0|[1-9]\d*)$/.test(String(value)) + || !Number.isSafeInteger(Number(value))) throw new Error('原生夹具数量须为非负安全整数') + return Number(value) +} const encode = encodeURIComponent const ok = response => response?.http === 200 && id(response.body?.code) === '200' const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] @@ -22,7 +27,7 @@ const localTime = delay => { export const preparationContract = Object.freeze({ stage: fixtureContract.stage, inputs: 'execute:true, current-run context, warehouseId (public active OVERSEAS with RECEIVING), destinationWarehouseId (public active FBA), pdaToken bound to warehouseId, readInventory(scope) returning all raw balances/positions/ledger with CUSTOMER_WAREHOUSE_COMPLETE scope', - writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; two dispatches, one shared trip with a test vehicle/qualified driver, two outbounds and deliveries. No picking.', + writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and a TEST vehicle/qualified driver; both reviews approved. No picking or delivery arrangement yet.', lifecycle: 'Caller creates/disables PDA identity and supplies raw readFleetInventory for verification. This helper logs out its own merchant sessions, retains all business fixtures, and refuses automatic retry. No SQL, environment files or external bookings.', }) @@ -59,8 +64,8 @@ export async function createFleetCrossCustomerFixture(context, { execute = false inboundCode: member.incomingNo, page: '1', size: '200' }) const grouped = await request('GET', `${ROOT}/inventory/balances?${query}`, undefined, 'Read exact fresh receipt balance page') const ledger = await request('GET', `${ROOT}/inventory/ledger?${query}&businessOrderNo=${encode(member.incomingNo)}`, undefined, 'Read exact receipt movement page') - check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && Number(grouped.total) === 1 - && ledger?.records?.length === 1 && Number(ledger.total) === 1 && id(grouped.records[0].id)) + check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && safeFixtureCount(grouped.total) === 1 + && ledger?.records?.length === 1 && safeFixtureCount(ledger.total) === 1 && id(grouped.records[0].id)) const path = `${ROOT}/inventory/balances/${encode(grouped.records[0].id)}` const balance = await request('GET', `${path}?${own(member)}`, undefined, 'Read original raw balance identity') const positions = await request('GET', `${path}/positions?${own(member)}`, undefined, 'Read original raw physical position') @@ -172,11 +177,11 @@ export async function createFleetCrossCustomerFixture(context, { execute = false member.palletId = id(pallet.warehousePalletId); member.palletNo = pallet.palletNo const bound = await request('POST', `${palletPath}/bind`, { palletCode: member.palletNo, idempotencyKey: key(`BIND-${suffix}`) }, 'Bind scanned physical pallet', pdaToken) check('Bound pallet is exactly the measured one-box pallet', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND' - && id(bound.pallets[0].warehousePalletId) === member.palletId && Number(bound.pallets[0].cartonCount) === 1) + && id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 1) const photoPath = `${pdaPath}/measurement-photos/files` const photo = record('POST', photoPath, undefined, await context.requestMultipart('POST', photoPath, { name: `${member.customerMarker}-TEST-MEASUREMENT.png`, type: 'image/png', content: testImage }, pdaToken), 'Upload TEST measurement evidence') - check('Measurement evidence is a stored file', photo?.fileId && Number(photo.sizeBytes) > 0) + check('Measurement evidence is a stored file', photo?.fileId && safeFixtureCount(photo.sizeBytes) > 0) incoming = await request('POST', `${pdaPath}/palletize`, { idempotencyKey: key(`PALLETIZE-${suffix}`), photoFileIds: [id(photo.fileId)] }, 'Complete real PDA receipt', pdaToken) check('Real PDA receipt completed with original physical pallet', incoming?.status === 'WAIT_CUSTOMER_CONFIRMATION' && incoming.pallets?.length === 1 && incoming.pallets[0].status === 'COMPLETED' && id(incoming.pallets[0].warehousePalletId) === member.palletId) @@ -184,73 +189,51 @@ export async function createFleetCrossCustomerFixture(context, { execute = false check('Customer confirmation makes ordinary incoming dispatchable', incoming?.transferNo === member.incomingNo && incoming.status === 'READY_FOR_DISPATCH') member.receipt = await stock(member) member.balanceId = id(member.receipt.balance.id); member.positionId = id(member.receipt.positions[0].id); member.receiptVerified = true - const dispatchBase = `${ROOT}/dispatches` - const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: member.incomingNo })}`, undefined, 'Read own real received dispatch candidate') - check('Candidate has exact customer, incoming, warehouse and physical pallet ownership', candidates?.length === 1 - && id(candidates[0].customerId) === member.customerId && id(candidates[0].overseasWarehouseId) === id(warehouseId) - && candidates[0].inboundCode === member.incomingNo && id(candidates[0].warehousePalletId) === member.palletId - && candidates[0].palletNo === member.palletNo && candidates[0].sourceStage === 'RECEIVED' && Number(candidates[0].packageCount) === 1 && candidates[0].planPalletId) - member.draftToken = key(`DRAFT-${suffix}`) - const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: member.draftToken, capacity: 30 }, 'Create isolated dispatch draft') - check('Dispatch draft has explicit container identity', container?.id) - member.draftContainerId = id(container.id) - await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidates[0].planPalletId), draftToken: member.draftToken, - draftContainerId: member.draftContainerId }, 'Lock this customer received pallet') - const dispatch = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds: [id(candidates[0].planPalletId)], draftToken: member.draftToken }, 'Prearrange one independent customer dispatch') - check('Dispatch contains exactly this unpicked pallet', dispatch?.dispatchNo && dispatch.status === 'PENDING_DEPARTURE' - && dispatch.items?.length === 1 && id(dispatch.items[0].warehousePalletId) === member.palletId - && Number(dispatch.packageCount) === 1 && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) - member.dispatchNo = dispatch.dispatchNo + member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true, + sourceType: 'WAREHOUSE_TRANSFER_PDA', finalizedPallet: incoming.pallets?.find(row => id(row.warehousePalletId) === member.palletId) } + const candidates = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`, + undefined, 'Read own native received source selection') + check('Candidate list is complete and has exact customer, incoming, warehouse and physical pallet ownership', candidates?.complete === true + && candidates.candidates?.length === 1 && id(candidates.candidates[0].customerId) === member.customerId + && id(candidates.candidates[0].warehouseId) === id(warehouseId) && candidates.candidates[0].inboundCode === member.incomingNo + && candidates.candidates[0].palletNo === member.palletNo && safeFixtureCount(candidates.candidates[0].availablePackages) === 1 + && candidates.candidates[0].selection?.sourceType === 'INBOUND_PLAN' && candidates.candidates[0].selection.planPalletId + && candidates.candidates[0].sourceVersion && candidates.candidates[0].selection.sourceVersion === candidates.candidates[0].sourceVersion + && candidates.candidates[0].blockReasons?.length === 0) + member.selection = candidates.candidates[0].selection + member.destination = candidates.candidates[0].destination } - for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId', 'dispatchNo']) + for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId']) check(`Both customers have independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2) - const tripProof = await prepareFleetTrip(context, MODULE, proof.members[0].dispatchNo, id(warehouseId)) + proof.createDispatchRequest = { idempotencyKey: key('CREATE_ONE_NATIVE_DISPATCH'), warehouseId: id(warehouseId), + vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, + remark: `${context.marker} TEST跨客户同仓一张排车一趟车`, cargo: proof.members.map(member => member.selection), batchAllocations: [] } + const created = await request('POST', `${ROOT}/dispatches`, proof.createDispatchRequest, 'Create one native dispatch from both real customers') + check('One native Create returns both unpicked pallets without client-supplied customer identity', created?.dispatchNo && created.status === 'ACTIVE' + && created.stage === 'BEFORE_LOADING' && safeFixtureCount(created.counts?.packageCount) === 2 && safeFixtureCount(created.counts?.pickedPackageCount) === 0 && safeFixtureCount(created.counts?.shippedPackageCount) === 0) + proof.dispatchNo = created.dispatchNo + for (const member of proof.members) member.dispatchNo = proof.dispatchNo + const tripProof = await prepareFleetTrip(context, MODULE, proof.dispatchNo, id(warehouseId)) proof.tripNo = tripProof.tripNo - const tripPath = `${ROOT}/fleet/trips/${encode(proof.tripNo)}` - let detail = await request('GET', tripPath, undefined, 'Read first member trip version') - detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion, - dispatchNo: proof.members[1].dispatchNo, reason: context.marker }, 'Bind second independent customer dispatch') - const loading = await request('GET', `${tripPath}/loading`, undefined, 'Read complete two-customer cargo destinations') - check('Shared trip cargo consists of exactly both owned physical pallets', loading?.cargo?.length === 2 && proof.members.every(member => - loading.cargo.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId - && id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destinationKey && Number(row.packages) === 1))) - const destinations = new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])) - detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion, - plannedDepartureAt: detail.trip.plannedDepartureAt, expectedDriverEndAt: detail.trip.expectedDriverEndAt, - stops: [{ name: origin.warehouse.warehouseName }, ...destinations.values()], reason: context.marker }, 'Replace route with complete actual destinations') - tripProof.stops = detail.trip.stops - check('Route includes every actual destination', [...destinations.keys()].every(destinationKey => detail.trip.stops?.slice(1).some(row => row.destinationKey === destinationKey))) - for (const [index, member] of proof.members.entries()) { - const path = `${ROOT}/dispatches/${encode(member.dispatchNo)}` - const freight = await request('GET', `${path}/external-freight`, undefined, 'Verify ordinary incoming has no external quote') - check('Ordinary incoming requires its own explicit delivery fee', freight?.requiresDeliveryFee === true && freight.quotes?.length === 0) - const transferred = await request('POST', `${path}/transfer-delivery`, { idempotencyKey: key(`DELIVERY-${index}`), plannedDeliveryAt: localTime(21600000), - deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', - vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate, - driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', remark: member.customerMarker, - fees: [{ feeType: 'DELIVERY_FEE', feeName: '受控测试派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, 'Create this customer outbound and delivery') - check('One customer dispatch produces one outbound and one delivery', transferred?.dispatchNo === member.dispatchNo && transferred.outboundNo - && transferred.deliveries?.length === 1 && transferred.deliveries[0].deliveryNo && Number(transferred.packageCount) === 1) - member.outboundNo = transferred.outboundNo; member.deliveryNo = transferred.deliveries[0].deliveryNo - const delivery = await request('GET', `${ROOT}/delivery-orders/${encode(member.deliveryNo)}?${own(member)}`, undefined, 'Verify independently owned delivery') - const outbound = await request('GET', `${ROOT}/outbound-orders/${encode(member.outboundNo)}?${own(member)}`, undefined, 'Verify independently owned outbound') - const dispatch = await request('GET', path, undefined, 'Verify customer dispatch remains unpicked') - check('Customer execution records are distinct and untouched', id(delivery?.order?.customerId) === member.customerId - && delivery.order.deliveryNo === member.deliveryNo && delivery.order.dispatchNo === member.dispatchNo && delivery.order.outboundNo === member.outboundNo - && delivery.order.status === 'WAIT_DELIVERY' && outbound?.outbound?.outboundNo === member.outboundNo && outbound.outbound.dispatchNo === member.dispatchNo - && id(outbound.outbound.overseasWarehouseId) === id(warehouseId) && outbound.outbound.status === 'WAIT_OUTBOUND' - && outbound.deliveries?.length === 1 && id(outbound.deliveries[0].customerId) === member.customerId && outbound.deliveries[0].deliveryNo === member.deliveryNo - && dispatch?.status === 'PENDING_DEPARTURE' && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) - const finalStock = await stock(member) - check('Trip preparation preserves original available receipt records', JSON.stringify(finalStock) === JSON.stringify(member.receipt)) + const dispatchPath = `${ROOT}/dispatches/${encode(proof.dispatchNo)}` + const detail = await request('GET', dispatchPath, undefined, 'Read one reviewed native cross-customer dispatch') + const items = await request('GET', `${dispatchPath}/items`, undefined, 'Read native per-customer cargo identities') + check('Both actual customer pallets share one reviewed native dispatch before any picking', detail.dispatchNo === proof.dispatchNo + && detail.status === 'ACTIVE' && detail.stage === 'LOADING' && detail.reviewState === 'APPROVED' + && safeFixtureCount(detail.counts?.packageCount) === 2 && safeFixtureCount(detail.counts?.pickedPackageCount) === 0 && safeFixtureCount(detail.counts?.shippedPackageCount) === 0 + && detail.documents?.outbounds?.length === 0 && detail.documents?.deliveries?.length === 0 + && items.dispatchNo === proof.dispatchNo && id(items.recordVersion) === id(detail.recordVersion) && items.items?.length === 2 + && proof.members.every(member => items.items.some(row => id(row.customerId) === member.customerId + && id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destination?.key === member.destination?.key + && safeFixtureCount(row.scheduledPackages) === 1 && safeFixtureCount(row.pickedPackages) === 0 && row.dispatchItemId))) + for (const member of proof.members) { + member.dispatchItemId = id(items.items.find(row => id(row.customerId) === member.customerId).dispatchItemId) + check('Trip preparation preserves original available receipt records', JSON.stringify(await stock(member)) === JSON.stringify(member.receipt)) } - detail = await request('GET', tripPath, undefined, 'Verify final two-customer shared trip membership') - check('Final trip has only the two fresh customers and remains planned', detail?.trip?.status === 'PLANNED' && detail.dispatches?.length === 2 - && proof.members.every(member => detail.dispatches.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId))) - for (const field of ['outboundNo', 'deliveryNo']) check(`Independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2) - fixture = { marker: context.marker, stage: fixtureContract.stage, tripNo: proof.tripNo, warehouseId: id(warehouseId), pdaToken, - members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified }) => - ({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified })) } + fixture = { marker: context.marker, stage: fixtureContract.stage, dispatchNo: proof.dispatchNo, tripNo: proof.tripNo, + warehouseId: id(warehouseId), pdaToken, tripProof, + members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo, receiptVerified, measurementReceipt, destination }) => + ({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo, receiptVerified, measurementReceipt, destination })) } } catch (error) { failure = error } // Always attempt every successfully opened session, including partial fixture failures. PDA lifecycle belongs to caller. const cleanupErrors = [] diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs index 7abd55d8c..b03b98150 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs @@ -1,90 +1,196 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { verifyFleetExternalDispatch } from './fleet-external-dispatch-acceptance.mjs' +import { readFileSync } from 'node:fs' +import { verifyFleetExternalDispatch, nativeArrangementGroups } from './fleet-external-dispatch-acceptance.mjs' -function fixture(fault = '') { - const segment = { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 }, - remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } } - const tripProof = { tripNo: 'TR-CURRENT', warehouseId: '92', dispatchNo: 'DP-OWN', - stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], externalReceipt: { - status: 'VERIFIED', createRequest: { customerId: '900719925474099301' }, incomingNo: 'WT-CURRENT', bookingNo: 'FB-CURRENT', - warehousePalletId: '900719925474099311', palletNo: 'PALLET-EXT', finalSegments: [segment], finalBooking: { - cargo: { destinationKey: 'FBA:ONT8' }, receipt: { source: { pallets: [{ weightKg: 100, volumeCbm: 1 }] } }, - }, - } } - let version = 10, linked = false, replanned = false, transferred = false - const calls = [], assertions = [] - const customerId = tripProof.externalReceipt.createRequest.customerId - const item = { warehousePalletId: tripProof.externalReceipt.warehousePalletId, packageCount: 1 } - const booking = () => ({ bookingNo: 'FB-CURRENT', status: linked ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', recordVersion: linked ? 4 : 3, - dispatchNo: linked ? 'DP-EXT' : null }) - const binding = () => ({ tripRecordVersion: version, booking: booking(), replayed: false }) - const dispatches = [{ dispatchNo: 'DP-OWN', review: { status: 'PENDING' } }] - const detail = () => ({ trip: { recordVersion: version, maxPalletSpaces: 30, vehiclePlate: 'TEST-TRUCK', driverName: 'TEST-DRIVER', driverPhone: '19900000000' }, dispatches }) - const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }) - const context = { marker: 'OFFLINE', createdResources: { fleetTrips: [tripProof] }, - assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) }, - async request(method, path, body) { - calls.push({ method, path, body }) - if (path.endsWith('/capacity-plan')) { - if (method === 'POST') { replanned = true; version++ } - return response({ recordVersion: version, manifestHash: 'a'.repeat(64), planValid: !linked || replanned || fault === 'stale-plan', - capacityPlan: { pallets: [{ warehousePalletId: 'OWN-PALLET', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] }, - cargo: [{ dispatchNo: 'DP-OWN', warehousePalletId: 'OWN-PALLET', packages: 1 }, ...(linked ? [{ dispatchNo: 'DP-EXT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, packages: 1 }] : [])], - availableSegments: replanned && fault === 'double-capacity' ? [{ ...segment, remaining: { ...segment.remaining, palletSpaces: 27 } }] : [segment] }) - } - if (path.includes('/candidates?')) return response([{ customerId: fault === 'foreign-customer' ? 'wrong' : customerId, overseasWarehouseId: '92', - inboundCode: 'WT-CURRENT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, palletNo: 'PALLET-EXT', packageCount: 1, - sourceStage: 'RECEIVED', planPalletId: 'PLAN-PALLET' }]) - if (path.endsWith('/drafts/containers')) return response({ id: 'CONTAINER' }) - if (path.endsWith('/draft-locks/lock')) return response(null) - if (path.endsWith('/pre-arrange')) return response({ dispatchNo: 'DP-EXT', packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0, items: [item] }) - if (path.endsWith('/bookings')) return response({ tripRecordVersion: version, records: [booking()] }) - if (path.endsWith('/bookings/FB-CURRENT/dispatch')) { - if (linked) return response({ ...binding(), replayed: true }) - linked = true; version++; dispatches.push({ dispatchNo: 'DP-EXT', review: { status: 'PENDING' } }); return response(binding()) - } - if (path.endsWith('/review')) { - assert.equal(body.recordVersion, version); version++ - const number = path.split('/').at(-2) - if (fault !== 'review-not-applied') dispatches.find(row => row.dispatchNo === number).review.status = 'APPROVED' - return response(detail()) - } - if (path.endsWith('/trips/TR-CURRENT')) return response(detail()) - if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: false, snapshotHash: 'b'.repeat(64), quotes: [ - { bookingNo: 'FB-CURRENT', incomingNo: 'WT-CURRENT', currency: 'CNY', amount: fault === 'stale-price' ? 50 : 30, quantity: 1, unitPrice: 30 }, - ] }) - if (path.endsWith('/transfer-delivery')) { - if (body.fees.length) return response(null, '本排车全部货物已带入外接约定运费,请勿重复填写派送应收') - const replayed = transferred; transferred = true - return response({ dispatchNo: 'DP-EXT', outboundNo: 'OUT-EXT', packageCount: 1, deliveries: [{ deliveryNo: 'DEL-EXT' }], replayed }) - } - assert.match(path, /\/delivery-orders\/DEL-EXT\?customerId=900719925474099301$/) - const rows = [{ feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }] - if (fault === 'duplicate-fee' && calls.filter(row => row.path === path).length > 1) rows.push({ ...rows[0] }) - return response({ order: { dispatchNo: 'DP-EXT' }, fees: rows }) - }, +const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', TRIP = '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT' +const OWN = '900719925474099301', EXT = '900719925474099302', clone = value => structuredClone(value) +function fixture(fault = '', nativeMode = false) { + const calls = [], assertions = [], ledger = new Map() + const externalCustomer = nativeMode ? OWN : EXT + const destination = n => ({ key: `FBA:A${nativeMode ? 1 : n}`, warehouseCode: `A${nativeMode ? 1 : n}` }) + const item = (n, customerId = OWN) => ({ dispatchItemId: `11${n}`, sourceKey: `PLAN:2${n}`, sourceVersion: 'a'.repeat(64), sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, warehousePalletId: `3${n}`, palletNo: `PAL-${n}`, + customerId, inboundCode: n === 3 ? 'WT-CURRENT' : nativeMode ? `WT-OWN-${n}` : `IN-${n}`, orderNo: n === 3 ? 'WT-CURRENT' : nativeMode ? `WT-OWN-${n}` : `ORDER-${n}`, destination: destination(n === 3 ? 1 : n), scheduledPackages: nativeMode ? 1 : n === 3 ? 3 : 2, pickedPackages: 0 }) + const items = [item(1), item(2, nativeMode ? EXT : OWN)], fullItems = [item(4)], external = item(3, externalCustomer) + const segment = { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 4, weightKg: 57, volumeCbm: 1.1 }, remaining: { palletSpaces: 26, weightKg: 17943, volumeCbm: 88.9 } } + const bookingCargo = { destinationKey: 'FBA:A1', packageCount: nativeMode ? 1 : 3, palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, priceBasis: 'PALLET', unitPrice: 15, currency: 'CNY' } + const accepted = { source: { incomingNo: 'WT-CURRENT', inboundCode: 'WT-CURRENT', warehouseId: '92', destinationKey: 'FBA:A1', status: 'READY_FOR_DISPATCH', receiptHash: 'd'.repeat(64), receivedPackages: nativeMode ? 1 : 3, + pallets: [{ warehousePalletId: '33', palletNo: 'PAL-3', packages: nativeMode ? 1 : 3, weightKg: 37, volumeCbm: 0.7 }] }, pallets: [{ warehousePalletId: '33', palletSpaces: 2, weightKg: 37, volumeCbm: 0.7 }], operatorId: 'OP', confirmedAt: '2026-10-19T01:00:00Z' } + const currentCase = { marker: 'CURRENT', mode: 'PARTIAL', dispatchNo: 'DS-OWN', customerId: OWN, transferBody: { plannedDeliveryAt: '2026-10-22T01:00:00Z', loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '未覆盖派送费', amount: 17.5, currency: 'USD' }] } } + const full = { ...clone(currentCase), mode: 'FULL', dispatchNo: 'DS-FULL' } + const tripProof = { tripNo: 'TR-CURRENT', dispatchNo: 'DS-OWN', warehouseId: '92', vehicleId: '71', driverId: '72', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }], + externalReceipt: { marker: 'CURRENT', status: 'VERIFIED', incomingNo: 'WT-CURRENT', bookingNo: 'BK-CURRENT', warehousePalletId: '33', palletNo: 'PAL-3', createRequest: { customerId: externalCustomer }, finalSegments: [segment], finalBooking: { cargo: bookingCargo, agreedAmount: 30 } } } + if (nativeMode) tripProof.stops.pop() + let recordVersion = 0, fleetVersion = 0, stage = fault === 'already-loaded' ? 'AFTER_LOADING' : 'LOADING', bound = false, planned = false, review = true, saved + const booking = () => ({ bookingNo: 'BK-CURRENT', customerId: externalCustomer, status: bound ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', dispatchNo: bound ? 'DS-OWN' : null, recordVersion: bound ? 1 : 0, cargo: bookingCargo, agreedAmount: 30, receipt: accepted }) + const counts = rows => ({ itemCount: rows.length, palletCount: rows.length, packageCount: rows.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: rows.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 }) + const detail = (full = false) => ({ dispatchNo: full ? 'DS-FULL' : 'DS-OWN', recordVersion: full ? 0 : recordVersion, status: 'ACTIVE', stage: full ? 'AFTER_LOADING' : stage, + reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, + counts: counts(full ? fullItems : items), pickingReconciled: !full && items.every(i => i.pickedPackages === i.scheduledPackages), dispatchInfo: { route: 'A1,A2' }, + orderDetails: (full ? fullItems : items).map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) + const ack = (action, result = {}) => ({ ...detail(), action, result, replayed: false }) + const groups = () => { + const raw = items.map((i, index) => { + const isExternal = i.sourceKey === 'PLAN:23', groupKey = nativeMode ? `G-${i.customerId}` : `G-${i.sourceKey}` + return { groupKey, customerId: i.customerId, destination: i.destination, groupCargoHash: `${index + 1}`.repeat(64), feeCoverageCargoHash: `${index + 1}`.repeat(64), externalFreightHash: 'b'.repeat(64), blockReasons: [], requiredServices: isExternal ? [] : ['DELIVERY_FEE'], + cargo: [{ ...i, sourceRegion: isExternal || nativeMode ? 'LASTLEG' : 'FIRSTLEG', sourceInboundId: isExternal || nativeMode ? null : `9007199254740994${index}`, orderId: `4${index}`, packages: i.scheduledPackages }], + externalQuotes: isExternal ? [{ customerId: externalCustomer, groupKey, bookingNo: 'BK-CURRENT', inboundCode: 'WT-CURRENT', currency: 'CNY', amount: fault === 'stale-price' ? 50 : 30, sourceHash: 'b'.repeat(64) }] : [], + feeSources: isExternal || nativeMode ? [] : [{ financeFeeId: `9007199254740995${index}`, customerId: OWN, currency: 'CNY', amount: 200, sourceVersion: 1, sourceHash: `FEE-${index}`, status: 'AVAILABLE', services: ['DELIVERY_FEE'], sourceKeys: [i.sourceKey] }] } + }) + if (!nativeMode) return raw + const merged = new Map() + for (const group of raw) { const previous = merged.get(group.groupKey); if (previous) { previous.cargo.push(...group.cargo); previous.externalQuotes.push(...group.externalQuotes) } else merged.set(group.groupKey, group) } + return [...merged.values()].map(g => ({ ...g, requiredServices: g.cargo.some(c => !g.externalQuotes.some(q => q.inboundCode === c.inboundCode)) ? ['DELIVERY_FEE'] : [] })) } - return { context, tripProof, calls, assertions } + const trip = () => ({ trip: { tripNo: 'TR-CURRENT', warehouseId: '92', vehicleId: '71', driverId: '72', recordVersion: fleetVersion, stops: tripProof.stops }, dispatches: [{ dispatchNo: fault === 'second-dispatch' ? 'DS-OTHER' : 'DS-OWN' }] }) + const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: 'CURRENT', createdResources: nativeMode ? { fleetTrips: [tripProof] } : { fleetTrips: [tripProof, { dispatchNo: 'DS-FULL', tripNo: 'TR-FULL', warehouseId: '92' }], fleetFeeCoverageCases: [full, currentCase] }, checkpoint: async () => {}, + assert(_module, name, _response, passed) { assertions.push({ name, passed }) }, addStep(_module, name, _response, passed) { assertions.push({ name, passed }) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, + async request(method, raw, body, token) { + calls.push({ method, path: raw, body: clone(body), token }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), key = `${normalized}:${body?.idempotencyKey}` + if (method !== 'GET' && body?.idempotencyKey && ledger.has(key)) { const original = ledger.get(key); assert.deepEqual(body, original.body); if (fault === 'replay-row-drift' && path.endsWith('/items')) items[0].dispatchItemId = '999'; return response({ ...clone(original.data), replayed: true }) } + const remember = data => { if (body?.idempotencyKey) ledger.set(key, { body: clone(body), data: clone(data) }); return response(data) } + if (path === `${TRIP}/capacity-plan`) { + if (method === 'POST') { assert.equal(body.recordVersion, fleetVersion); assert.equal(body.pallets.length, 3); assert.equal(body.pallets.find(p => p.warehousePalletId === '33').palletSpaces, 2); planned = true; fleetVersion++ } + return response({ recordVersion: fleetVersion, manifestHash: 'c'.repeat(64), planValid: items.length === 2 || planned || fault === 'stale-plan', + cargo: items.map(i => ({ dispatchNo: 'DS-OWN', warehousePalletId: i.warehousePalletId, packages: i.scheduledPackages })), + capacityPlan: { pallets: items.slice(0, 2).map((i, n) => ({ warehousePalletId: i.warehousePalletId, palletSpaces: 1, weightKg: 10, volumeCbm: 0.2, deliveryIndex: nativeMode ? 1 : n + 1 })) }, + availableSegments: planned && fault === 'double-capacity' ? [{ ...segment, remaining: { ...segment.remaining, palletSpaces: 25 } }] : [segment] }) + } + if (path === `${TRIP}/bookings`) return response({ tripRecordVersion: fleetVersion, records: [booking()] }) + if (path === `${TRIP}/bookings/BK-CURRENT/dispatch`) { assert.equal(body.dispatchNo, 'DS-OWN'); assert.equal(body.tripRecordVersion, fleetVersion); assert.equal(body.recordVersion, 0); bound = true; fleetVersion++; return remember({ tripRecordVersion: fleetVersion, booking: booking(), replayed: false }) } + if (path === TRIP) return response(trip()) + if (path === `${BASE}/candidates`) { + assert.equal(params.get('warehouseId'), '92'); assert.equal(params.get('sourceStage'), 'RECEIVED'); assert.equal(params.get('orderNo'), 'WT-CURRENT') + return response({ complete: true, snapshotAt: '2026-10-19', candidates: [{ selection: { sourceType: 'INBOUND_PLAN', planPalletId: '23', returnReceiptId: null, relabelNo: null, warehousePalletId: '33', sourceVersion: 'a'.repeat(64) }, + sourceKey: 'PLAN:23', sourceVersion: 'a'.repeat(64), customerId: fault === 'foreign-customer' ? '999' : externalCustomer, warehouseId: '92', inboundCode: 'WT-CURRENT', palletNo: 'PAL-3', availablePackages: nativeMode ? 1 : 3, destination: destination(1), appointmentBatches: [], blockReasons: [] }] }) + } + if (path === BASE) return response({ records: [{ ...detail(params.get('dispatchNo') === 'DS-FULL'), warehouseId: '92' }] }) + if (raw.includes('/outbound-orders?')) return response({ records: [], total: 0, page: 1, size: 200 }) + if (path.includes('/finance-fee-sources')) { + assert.equal(params.get('customerId'), externalCustomer); assert.equal(params.get('businessNo'), nativeMode ? 'DEL-0' : 'DEL-2') + return response([{ id: '980', sourceFeeId: '970', customerId: fault === 'foreign-source-fee' ? externalCustomer === OWN ? EXT : OWN : externalCustomer, direction: 'RECEIVABLE', feeItemCode: 'DELIVERY_FEE', inboundCode: 'WT-CURRENT', businessNo: nativeMode ? 'DEL-0' : 'DEL-2', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', originalCurrency: 'CNY', originalAmount: 30, chargeableQuantity: fault === 'wrong-quantity' ? 1 : 2, unitPrice: 15, quantityUnit: '托位' }]) + } + const delivery = path.match(/\/delivery-orders\/DEL-(\d)$/) + if (delivery) { + const index = Number(delivery[1]), group = groups()[index], input = saved.groups[index], who = group.customerId + assert.equal(params.get('customerId'), who) + const feeCoverage = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: group.cargo, operatorId: 'OP', confirmedAt: '2026-10-19T01:00:00Z', + references: input.feeCoverage.map(s => ({ services: s.services, source: { ...group.feeSources.find(f => f.financeFeeId === s.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId } })) } : null + const fees = input.fees.map(f => ({ id: String(981 + index), feeDirection: f.direction, feeType: f.feeType, amount: f.amount, currency: f.currency, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' })) + if (group.externalQuotes.length) fees.push({ id: '980', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', amount: 30, currency: 'CNY', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }) + if (fault === 'duplicate-fee' && group.externalQuotes.length && calls.filter(c => c.path === raw).length > 1) fees.push(clone(fees[0])) + return response({ order: { customerId: who, dispatchNo: 'DS-OWN', deliveryNo: `DEL-${index}`, outboundNo: who === EXT ? 'OUT-EXT' : 'OUT-OWN', destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage }) + } + const isFull = path.startsWith(`${BASE}/DS-FULL`), data = detail(isFull), cargo = isFull ? fullItems : items + if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) }) + if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo }) + if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops }) + if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1, + evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) }) + if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return response({ ...data, cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), groups: groups(), arrange: { allowed: !saved }, arrangement: saved ? { dispatchNo: 'DS-OWN' } : null }) + if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) } + if (path.endsWith('/pick') && body.recordVersion !== recordVersion) return response(null, '公共版本已变化') + assert.equal(body.recordVersion, recordVersion, raw) + if (path.endsWith('/return-to-review')) { stage = 'BEFORE_LOADING'; review = false; recordVersion++; return remember(ack('RETURN_TO_REVIEW')) } + if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) } + if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) } + if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) } + if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'AFTER_LOADING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + assert.ok(path.endsWith('/transfer-delivery'), raw); assert.equal(stage, 'AFTER_LOADING') + if (body.cargoHash !== 'a'.repeat(64)) return response(null, '装车或实际货物已经变化') + if (body.groups.some(g => g.feeCoverage.some(s => s.sourceVersion !== 1))) return response(null, '原费用版本已变化') + if (body.groups.some(g => g.externalFreightHash !== 'b'.repeat(64))) return response(null, '外接实收或约定运费已变化') + if (body.groups.some(g => new Set(g.fees.map(f => `${f.direction}:${f.feeType}`)).size !== g.fees.length)) return response(null, '同组同方向同费用不得重复登记') + if (body.groups.some(g => g.groupKey === 'G-PLAN:23' && g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收') + if (!body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对') + saved = clone(body); recordVersion++ + if (fault === 'unknown-arrange') return { http: 0, body: null } + return remember(ack('ARRANGE_DELIVERY', { dispatchNo: 'DS-OWN', cargoHash: 'a'.repeat(64), outbounds: [...new Set(items.map(i => i.customerId))].map(customerId => ({ customerId, outboundNo: customerId === OWN ? 'OUT-OWN' : 'OUT-EXT', status: 'WAIT_OUTBOUND', packageCount: items.filter(i => i.customerId === customerId).reduce((n, i) => n + i.scheduledPackages, 0) })), + deliveries: groups().map((g, n) => ({ groupKey: g.groupKey, customerId: g.customerId, destination: g.destination, outboundNo: g.customerId === EXT ? 'OUT-EXT' : 'OUT-OWN', deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY', packageCount: g.cargo.reduce((n, c) => n + c.packages, 0) })) })) + } } + const options = nativeMode ? { pickerToken: 'OFFLINE-ONLY', arrangement: { mode: 'NATIVE', marker: 'CURRENT', dispatchNo: 'DS-OWN', plannedDeliveryAt: '2026-10-22T01:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', appointmentAccount: 'SELF', + groups: [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户A明确派送费', currency: 'USD', amount: 11 }] }, { customerId: EXT, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户B明确派送费', currency: 'CNY', amount: 17 }] }] } } : { coverageCase: currentCase, pickerToken: 'OFFLINE-ONLY' } + return { context, tripProof, currentCase, calls, assertions, items, options } } -test('external dispatch helper preserves reservation when binding physical cargo and publishes only agreed source fee', async () => { - const f = fixture() - const proof = await verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof) - assert.equal(proof.status, 'VERIFIED') - assert.equal(proof.dispatchNo, 'DP-EXT') - assert.equal(proof.sourceFees.length, 1) - assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED') - assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) - assert.ok(f.calls.every(row => !/\/pick|\/depart|\/cancel/.test(row.path))) - await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof), /尚未执行关联/) +test('native external flow adds actual received cargo to same whole, re-reviews, scans, loads and arranges all customer groups', async () => { + const f = fixture(), proof = await verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.dispatchNo, 'DS-OWN'); assert.equal(proof.sourceFees.length, 1); assert.equal(proof.sourceFees[0].chargeableQuantity, 2) + assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED'); assert.equal(f.currentCase.proof.status, 'VERIFIED'); assert.equal(f.currentCase.proof.newReceivable, 17.5) + assert.ok(f.assertions.every(a => a.passed)); assert.equal(f.calls.some(c => /pre-arrange|dispatches\/bind|dispatches\/unbind|\/cancel/.test(c.path)), false) + assert.equal(f.calls.filter(c => c.method === 'POST' && c.path === BASE).length, 0) + assert.ok(f.calls.findIndex(c => c.path.endsWith('/return-to-review')) < f.calls.findIndex(c => c.path.endsWith('/items') && c.method === 'POST')) + assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery'))) + assert.equal(f.items.length, 3); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /尚未装入/) }) -for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee']) { - test(`external dispatch proof blocks ${fault}`, async () => { - const f = fixture(fault) - await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof)) - assert.equal(f.tripProof.externalDispatch.status, 'CHECKING') - assert.ok(f.assertions.some(row => !row.passed)) - }) -} +for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee', 'foreign-source-fee', 'wrong-quantity', 'replay-row-drift', 'second-dispatch', 'already-loaded', 'unknown-add']) test(`native external proof rejects ${fault}`, async () => { + const f = fixture(fault); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options)); assert.notEqual(f.tripProof.externalDispatch.status, 'VERIFIED') + if (['foreign-customer', 'already-loaded'].includes(fault)) assert.equal(f.calls.some(c => c.method === 'POST'), false) + if (fault === 'unknown-add') { assert.equal(f.tripProof.externalDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(f.calls.at(-1).path.endsWith('/items'), true); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true) } +}) + +test('no external callback injection can replace real registered PARTIAL sources, current PDA or durable journal', async () => { + for (const mutate of [f => { delete f.options.coverageCase }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { + const f = fixture(); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /同主单未扫码PARTIAL/); assert.equal(f.calls.length, 0) + } +}) + +test('server binding requires the same existing whole dispatch and public cargo commands use versioned ADD_CARGO', () => { + const service = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java', import.meta.url), 'utf8') + assert.ok(service.includes('identity.tripId()!=trip.getId()')); assert.ok(service.includes('planning.validateIncomingDispatch')) + const source = readFileSync(new URL('./fleet-external-dispatch-acceptance.mjs', import.meta.url), 'utf8') + assert.equal(source.includes('/pre-arrange'), false); assert.equal(source.includes('draftToken'), false); assert.ok(source.includes('/return-to-review')); assert.ok(source.includes("'ADD_CARGO'")) +}) + +test('ordinary native mode accepts two WAREHOUSE_TRANSFER sources and same-customer external cargo without FULL/PARTIAL fixtures', async () => { + const f = fixture('', true), proof = await verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.arrangementMode, 'NATIVE'); assert.equal(proof.nativeArrangement.status, 'VERIFIED') + assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false); assert.equal(f.currentCase.proof, undefined) + assert.equal(proof.deliveries.length, 2); assert.equal(proof.deliveries.reduce((n, d) => n + d.packageCount, 0), 3) + assert.ok(f.items.some(i => i.inboundCode === 'WT-OWN-1') && f.items.some(i => i.inboundCode === 'WT-OWN-2')); assert.equal(f.items.length, 3) + const requests = f.calls.filter(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')), actual = requests.at(-1) + assert.deepEqual(proof.transferRequest.groups.map(g => g.fees.map(f => [f.currency, f.amount])), [[['USD', 11]], [['CNY', 17]]]) + assert.ok(proof.transferRequest.groups.every(g => g.feeCoverage.length === 0 && !g.groupKey.startsWith('FBA:'))) + assert.equal(requests.length, 5); assert.equal(actual.body.idempotencyKey, proof.transferRequest.idempotencyKey) + assert.ok(f.calls.findIndex(c => c.path.endsWith('/pick')) < f.calls.findIndex(c => c.path.endsWith('/loading-complete'))) + assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery'))) + assert.ok(f.calls.every(c => !c.path.includes('DS-FULL') && !c.path.includes('pre-arrange'))); assert.ok(f.assertions.every(a => a.passed)) + const snapshots = proof.nativeArrangement.snapshots + assert.deepEqual(snapshots['DEL-0'].fees.map(f => [f.currency, f.amount]), [['USD', 11], ['CNY', 30]]) + assert.deepEqual(snapshots['DEL-1'].fees.map(f => [f.currency, f.amount]), [['CNY', 17]]) + assert.equal(proof.sourceFees[0].originalAmount, 30); assert.equal(proof.sourceFees[0].customerId, OWN) +}) + +test('ordinary native configuration and coverage mode are exclusive and require current explicit group configuration', async () => { + for (const mutate of [f => { delete f.options.arrangement }, f => { f.options.coverageCase = f.currentCase }, f => { f.options.arrangement.mode = 'SAME_ROUTE' }, + f => { f.options.arrangement.marker = 'OLD' }, f => { f.options.arrangement.dispatchNo = 'OTHER' }, f => { f.options.arrangement.groups.push(clone(f.options.arrangement.groups[0])) }, + f => { f.options.arrangement.groups = async () => ({ success: true }) }, f => { f.options.arrangement.groups[0].fees[0].amount = 0 }, f => { f.options.arrangement.groups[0].fees.push(clone(f.options.arrangement.groups[0].fees[0])) }, + f => { f.options.arrangement.groups[0].feeCoverage = [] }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { + const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.equal(f.calls.length, 0) + } + for (const mutate of [f => { f.options.arrangement.groups.pop() }, f => { f.options.arrangement.groups[0].customerId = '999' }]) { + const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options), /加货前精确覆盖/); assert.equal(f.calls.some(c => c.method !== 'GET'), false) + } +}) + +for (const fault of ['foreign-customer', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee', 'foreign-source-fee', 'wrong-quantity', 'replay-row-drift', 'already-loaded', 'missing-measurements', 'unknown-arrange']) test(`ordinary native flow preserves protection against ${fault}`, async () => { + const f = fixture(fault, true); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.notEqual(f.tripProof.externalDispatch.status, 'VERIFIED') + assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false) + if (fault === 'missing-measurements') assert.equal(f.calls.some(c => c.path.endsWith('/loading-complete') || c.path.endsWith('/transfer-delivery')), false) + if (fault === 'unknown-arrange') { assert.equal(f.tripProof.externalDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(f.calls.at(-1).path.endsWith('/transfer-delivery'), true); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true); assert.ok(f.tripProof.externalDispatch.nativeArrangement.confirmationRequest.idempotencyKey) } +}) + +test('ordinary native plan permits explicit zero extra fee only for fully quoted cargo and never expands the quote into each group', () => { + const f = fixture('', true), receipt = f.tripProof.externalReceipt, plan = clone(f.options.arrangement) + plan.groups = [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [] }] + const group = { groupKey: 'REAL-GROUP', customerId: OWN, destination: { key: 'FBA:A1' }, groupCargoHash: 'a'.repeat(64), externalFreightHash: 'b'.repeat(64), feeCoverageCargoHash: 'a'.repeat(64), blockReasons: [], requiredServices: [], feeSources: [], + cargo: [{ customerId: OWN, inboundCode: receipt.incomingNo, destination: { key: 'FBA:A1' } }], externalQuotes: [{ groupKey: 'REAL-GROUP', customerId: OWN, bookingNo: receipt.bookingNo, inboundCode: receipt.incomingNo, currency: 'CNY', amount: 30, sourceHash: 'b'.repeat(64) }] } + const groups = nativeArrangementGroups({ groups: [group] }, plan, receipt) + assert.deepEqual(groups[0].fees, []); assert.deepEqual(groups[0].feeCoverage, []); assert.equal(groups[0].groupKey, 'REAL-GROUP') + plan.groups[0].fees = [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: 'duplicate', currency: 'CNY', amount: 30 }] + assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /不得重复登记/) + plan.groups[0].fees = []; group.requiredServices = ['DELIVERY_FEE']; assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /尚未覆盖/) +}) diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs index 76af87ca7..78d9b70b6 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs @@ -1,116 +1,244 @@ -import { randomUUID } from 'node:crypto' +import { DISPATCH_BASE as BASE, id as stringId, version, createBody, candidatePage, readDispatch, replay } from './grouped-dispatch-acceptance.mjs' +import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' -const OPTIONS = { scenario: 'fleet-external-dispatch-price', deploymentBlocker: true } -const id = value => value == null ? '' : String(value) -const key = () => `fleet-dispatch-${randomUUID()}` -const stable = rows => JSON.stringify(rows) - -/** Continues a current-run VERIFIED external receipt. Leaves both dispatches ready for the caller's real PDA picking. */ -export async function verifyFleetExternalDispatch(context, module, tripProof) { - const receipt = tripProof.externalReceipt - if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch) - throw new Error('外接关联只接受本轮已验证实收且尚未执行关联的车次') - const proof = tripProof.externalDispatch = { status: 'CHECKING', marker: context.marker, requests: [] } - const customerId = id(receipt.createRequest.customerId) - const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}` - const dispatchBase = '/api/platform/v1/last-leg/dispatches' - async function check(name, passed, response) { - context.assert(module, name, response, Boolean(passed), '', OPTIONS) - if (!passed) throw new Error(`${module}: ${name}`) +const clone = value => structuredClone(value) +const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) } +const money = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('外接约定金额或单价不完整'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) } +const identity = item => ({ dispatchItemId: id(item.dispatchItemId), sourceKey: item.sourceKey, customerId: id(item.customerId), warehousePalletId: id(item.warehousePalletId), scheduledPackages: Number(item.scheduledPackages), pickedPackages: Number(item.pickedPackages) }) +const groupIdentity = (customerId, destinationKey) => JSON.stringify([id(customerId), destinationKey]) +const sha = value => /^[a-f0-9]{64}$/i.test(value || '') +const feeIdentity = fee => `${fee.direction}:${fee.feeType}:${fee.currency}:${money(fee.amount)}` +function validateNativeArrangement(context, tripProof, plan) { + if (plan?.mode !== 'NATIVE' || plan.marker !== context.marker || plan.dispatchNo !== tripProof.dispatchNo + || typeof plan.plannedDeliveryAt !== 'string' || !Number.isFinite(Date.parse(plan.plannedDeliveryAt)) + || !plan.deliveryMethod || !plan.pickupMethod || plan.loadingType !== 'PALLET' || plan.appointmentAccount !== 'SELF' + || !Array.isArray(plan.groups) || !plan.groups.length) throw Error('普通原生安排须明确本轮主单、派送时间、PALLET/SELF及逐客户目的地费用') + const keys = new Set() + for (const group of plan.groups) { + const key = groupIdentity(group.customerId, group.destinationKey) + if (!/^[1-9]\d*$/.test(id(group.customerId)) || typeof group.destinationKey !== 'string' || !group.destinationKey.trim() || keys.has(key) || !Array.isArray(group.fees) || Object.hasOwn(group, 'feeCoverage')) throw Error('普通安排的客户目的地配置缺失、重复或含原费覆盖选择') + keys.add(key) + const fees = new Set() + for (const fee of group.fees) { + const feeKey = `${fee.direction}:${fee.feeType}` + if (!['RECEIVABLE', 'PAYABLE'].includes(fee.direction) || !['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].includes(fee.feeType) + || !fee.feeName || !['CNY', 'USD'].includes(fee.currency) || money(fee.amount) <= 0n || fees.has(feeKey)) throw Error('普通安排费用必须逐组明确正数、币种和服务,不得重复') + fees.add(feeKey) + } } - async function request(method, path, body, label, expectedMessage) { - const response = await context.request(method, path, body) - proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code, - requestId: response?.requestId, traceId: response?.traceId }) - const conflict = response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409') - await check(label, response?.requestId && response?.traceId && (expectedMessage - ? conflict && expectedMessage.test(id(response?.body?.message)) : response?.http === 200 && id(response?.body?.code) === '200'), response) - return response.body?.data +} + +// 仅将明确的客户/目的地配置映射到实时groupKey与摘要;不推算费用或原费覆盖。 +export function nativeArrangementGroups(preview, plan, receipt) { + const configured = new Map(plan.groups.map(g => [groupIdentity(g.customerId, g.destinationKey), g])) + if (!Array.isArray(preview.groups) || preview.groups.length !== configured.size || new Set(preview.groups.map(g => groupIdentity(g.customerId, g.destination?.key))).size !== configured.size) throw Error('普通安排配置必须精确覆盖全车全部真实客户目的地') + return preview.groups.map(group => { + const config = configured.get(groupIdentity(group.customerId, group.destination?.key)) + if (!config || !group.groupKey || !sha(group.groupCargoHash) || !sha(group.externalFreightHash) || !sha(group.feeCoverageCargoHash) + || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.requiredServices) || !Array.isArray(group.externalQuotes) + || !Array.isArray(group.feeSources) || !Array.isArray(group.cargo) || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('普通原生安排的实际分组或来源门禁不完整') + if (group.externalQuotes.some(q => q.bookingNo !== receipt.bookingNo || q.inboundCode !== receipt.incomingNo || id(q.customerId) !== id(group.customerId) + || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash || q.currency !== receipt.finalBooking.cargo.currency || money(q.amount) !== money(receipt.finalBooking.agreedAmount))) throw Error('普通安排的外接报价已变化或越过客户来源') + if (group.requiredServices.some(service => !config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === service))) throw Error('普通安排必须明确填写本组尚未覆盖的服务费用') + const quotedAll = group.cargo.every(c => group.externalQuotes.some(q => q.inboundCode === c.inboundCode)) + if (quotedAll && config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE')) throw Error('本组派送已由外接约定覆盖,不得重复登记应收') + return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: plan.plannedDeliveryAt, + deliveryMethod: plan.deliveryMethod, pickupMethod: plan.pickupMethod, loadingType: plan.loadingType, appointmentAccount: plan.appointmentAccount, + waybillNo: plan.waybillNo || null, carrierName: plan.carrierName || null, vehicleType: plan.vehicleType || null, vehicleSize: plan.vehicleSize || null, sellerName: null, remark: plan.remark || null, + externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: clone(config.fees) } + }) +} + +async function arrangeNative(context, module, io, proof, tripProof, plan, pickerToken, actualMeasurements) { + const current = await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements }) + const path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}`, preview = await io.get(`${path}/delivery-arrangement`, '普通模式读取装车后实时全车派送分组') + io.check('普通安排使用当前公共版本和真实实载摘要', null, preview?.dispatchNo === tripProof.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' + && id(version(preview.recordVersion)) === id(version(current.data.recordVersion)) && preview.arrange?.allowed === true && !preview.arrangement && sha(preview.cargoHash) && sha(preview.loadingCompletionHash)) + const groups = nativeArrangementGroups(preview, plan, tripProof.externalReceipt), totals = new Map() + for (const group of preview.groups) for (const row of group.cargo) { + io.check('普通派送货物保留实际客户目的地和原物理行', null, current.items.items.some(i => id(i.dispatchItemId) === id(row.dispatchItemId) && id(i.customerId) === id(group.customerId) && i.destination.key === group.destination.key && id(i.warehousePalletId) === id(row.warehousePalletId))) + totals.set(id(row.dispatchItemId), (totals.get(id(row.dispatchItemId)) || 0) + Number(row.packages)) } - const before = await request('GET', `${base}/capacity-plan`, undefined, '关联外接前冻结有效计划与预留') - await check('实际外接仍计入承诺容量', before?.planValid && before.capacityPlan?.pallets?.length > 0 - && stable(before.availableSegments) === stable(receipt.finalSegments)) - const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: receipt.incomingNo })}`, - undefined, '查询本轮真实外接来货的可排车实托') - await check('候选精确匹配本轮客户起运仓来货及实托', Array.isArray(candidates) && candidates.length === 1 - && id(candidates[0].customerId) === customerId && id(candidates[0].overseasWarehouseId) === id(tripProof.warehouseId) - && candidates[0].inboundCode === receipt.incomingNo && id(candidates[0].warehousePalletId) === receipt.warehousePalletId - && candidates[0].palletNo === receipt.palletNo && Number(candidates[0].packageCount) === 1 - && candidates[0].sourceStage === 'RECEIVED' && candidates[0].planPalletId) - const candidate = candidates[0] - proof.draftToken = key() - const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: proof.draftToken, capacity: 30 }, '建立本轮外接排车草稿') - await check('草稿返回明确容器引用', container?.id) - proof.draftContainerId = id(container.id) - await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidate.planPalletId), - draftToken: proof.draftToken, draftContainerId: proof.draftContainerId }, '正式锁定本轮外接实托') - proof.preArrangeRequest = { planPalletIds: [id(candidate.planPalletId)], draftToken: proof.draftToken } - const arranged = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '从真实已收货来源生成外接客户排车') - await check('外接排车只包含本票实收货物', arranged?.dispatchNo && arranged.dispatchNo !== tripProof.dispatchNo - && arranged.items?.length === 1 && id(arranged.items[0].warehousePalletId) === receipt.warehousePalletId - && Number(arranged.packageCount) === 1 && Number(arranged.pickedPackageCount) === 0 && Number(arranged.shippedPackageCount) === 0) - proof.dispatchNo = arranged.dispatchNo - const replay = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '重放外接预排不生成第二张排车') - await check('预排重放保持排车与实托', replay?.dispatchNo === proof.dispatchNo && stable(replay.items) === stable(arranged.items)) - const bookingPath = `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}` - const bookingList = await request('GET', `${base}/bookings`, undefined, '读取关联前最新预订及车次版本') - const booking = bookingList?.records?.find(row => row.bookingNo === receipt.bookingNo) - await check('本票仍为实收完成且未关联', booking?.status === 'RECEIVED' && booking.incomingNo === receipt.incomingNo && !booking.dispatchNo) - proof.bindRequest = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, - dispatchNo: proof.dispatchNo, idempotencyKey: key() } - const linked = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '将本票外接真实排车关联到承诺车次') - await check('关联后预订绑定同一排车', linked?.booking?.status === 'BOUND' && linked.booking.dispatchNo === proof.dispatchNo) - const linkedReplay = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '重放外接排车关联') - await check('关联重放不增加版本或第二次占位', linkedReplay?.replayed === true - && id(linkedReplay.tripRecordVersion) === id(linked.tripRecordVersion) && stable(linkedReplay.booking) === stable(linked.booking)) - const after = await request('GET', `${base}/capacity-plan`, undefined, '读取新增真实货物后的计划容量') - await check('新增外接货物使旧容量确认待重核', after?.planValid === false && after.cargo?.some(row => row.dispatchNo === proof.dispatchNo - && id(row.warehousePalletId) === receipt.warehousePalletId && Number(row.packages) === 1)) - const pallets = new Map(before.capacityPlan.pallets.map(row => [id(row.warehousePalletId), row])) - const actual = receipt.finalBooking.receipt.source.pallets[0] - pallets.set(receipt.warehousePalletId, { warehousePalletId: receipt.warehousePalletId, palletSpaces: 1, - weightKg: Number(actual.weightKg), volumeCbm: Number(actual.volumeCbm), - deliveryIndex: tripProof.stops.findIndex(stop => stop.destinationKey === receipt.finalBooking.cargo.destinationKey) }) - await check('新容量只引用原计划和已核实外接实测', after.cargo.every(row => pallets.has(id(row.warehousePalletId))) - && new Set(after.cargo.map(row => id(row.warehousePalletId))).size === pallets.size) - const replanned = await request('POST', `${base}/capacity-plan`, { recordVersion: after.recordVersion, manifestHash: after.manifestHash, - pallets: [...pallets.values()], reason: '外接预订转实际发运后按本轮实测重新确认,不重复计算原预留' }, '重新核对包含外接实物的全部货物容量') - await check('从预留转实际关联后分段总占用守恒', replanned?.planValid === true && stable(replanned.availableSegments) === stable(before.availableSegments)) - let detail = await request('GET', base, undefined, '读取本车所有客户排车待审核情况') - await check('自有货与外接客户排车均保留', detail?.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo) - && detail.dispatches.some(row => row.dispatchNo === proof.dispatchNo)) - for (const binding of detail.dispatches) detail = await request('POST', `${base}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, - { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }, '关联变化后逐客户通过调度审核') - await check('每张客户执行单都有通过事实', detail.dispatches.every(row => row.review?.status === 'APPROVED')) - const dispatchPath = `${dispatchBase}/${encodeURIComponent(proof.dispatchNo)}` - const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '读取本票实收约定运费') - await check('外接报价精确对应本票实收和确认单价', freight?.requiresDeliveryFee === false && freight.quotes?.length === 1 - && freight.quotes[0].bookingNo === receipt.bookingNo && freight.quotes[0].incomingNo === receipt.incomingNo - && freight.quotes[0].currency === 'CNY' && Number(freight.quotes[0].amount) === 30 - && Number(freight.quotes[0].quantity) === 1 && Number(freight.quotes[0].unitPrice) === 30) - proof.transferRequest = { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), - deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', - vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate, - driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', - remark: `${context.marker} 外接实收报价验收`, externalFreightHash: freight.snapshotHash, fees: [] } - await request('POST', `${dispatchPath}/transfer-delivery`, { ...proof.transferRequest, - idempotencyKey: key(), fees: [{ feeType: 'DELIVERY_FEE', feeName: '重复派送费测试', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, - '全部外接已有约定运费时拒绝重复派送应收', /重复|再次/) - const transferred = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '按实收报价建立本票派送及外接应收来源') - await check('外接只生成本目的地一张派送', transferred?.dispatchNo === proof.dispatchNo && transferred.outboundNo - && transferred.deliveries?.length === 1 && Number(transferred.packageCount) === 1) - proof.outboundNo = transferred.outboundNo; proof.deliveryNo = transferred.deliveries[0].deliveryNo - const deliveryPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(customerId)}` - const delivery = await request('GET', deliveryPath, undefined, '回查本票派送费用来源') - const fees = delivery?.fees?.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE') - await check('本票只登记约定30元应收来源且没有伪零费用', fees?.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY' - && fees[0].sourceStatus === 'ACTIVE' && fees[0].reviewStatus === 'APPROVED' && delivery.order?.dispatchNo === proof.dispatchNo) - const transferReplay = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '外接转派送原请求重放') - const replayedDelivery = await request('GET', deliveryPath, undefined, '重放后回查外接应收未重复') - await check('外接重放保持派送及费用来源', transferReplay?.replayed === true && transferReplay.outboundNo === proof.outboundNo - && stable(replayedDelivery.fees) === stable(delivery.fees)) - proof.customerId = customerId; proof.finalTripVersion = detail.trip.recordVersion - proof.sourceFees = fees; proof.financeAcknowledgement = 'NOT_VERIFIED' - proof.status = 'VERIFIED' - return proof + io.check('普通安排逐物理行箱数完整守恒', null, totals.size === current.items.items.length && current.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages))) + const body = { recordVersion: version(preview.recordVersion), idempotencyKey: io.key('native-arrangement'), cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups } + proof.nativeArrangement = { mode: 'NATIVE', confirmationRequest: clone(body), status: 'CHECKING' }; await context.checkpoint() + const noDocuments = async () => { + for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) { + const rows = []; let total + for (let page = 1; ; page++) { + const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId, page: String(page), size: '200' })}`, '普通安排拒绝前后按客户回查无出库写入') + io.check('普通安排出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0 && Number(data.page) === page && Number(data.size) === 200 + && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total))) + total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break + } + io.check('普通安排拒绝未生成本主单出库', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== tripProof.dispatchNo)) + } + } + const reject = async (action, changed, pattern) => { + const before = await readDispatch(io, tripProof.dispatchNo) + await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: io.key(action) }, action, pattern) + await noDocuments(); io.check(`${action}不改整车版本及实物`, null, JSON.stringify(await readDispatch(io, tripProof.dispatchNo)) === JSON.stringify(before)) + } + await noDocuments() + const changedHash = hash => `${hash[0] === '0' ? '1' : '0'}${hash.slice(1)}` + await reject('普通安排陈旧货物摘要拒绝', { cargoHash: changedHash(body.cargoHash) }, /货物.*变化/) + const quoted = preview.groups.find(g => g.externalQuotes.length) + io.check('普通安排包含本轮已确认外接报价', null, Boolean(quoted)) + const stale = clone(groups); stale.find(g => g.groupKey === quoted.groupKey).externalFreightHash = changedHash(quoted.externalFreightHash) + await reject('普通安排陈旧外接报价摘要拒绝', { groups: stale }, /外接.*变化/) + const duplicate = clone(groups), quote = quoted.externalQuotes[0] + duplicate.find(g => g.groupKey === quoted.groupKey).fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接应收反例', currency: quote.currency, amount: quote.amount }) + await reject('普通安排重复外接应收拒绝', { groups: duplicate }, /重复|覆盖/) + const command = await io.request('POST', `${path}/transfer-delivery`, body, '普通原生模式一次安排全车全部客户目的地'), result = command?.result + io.check('普通原生命令完整返回真实客户出库和目的地派送', null, command?.dispatchNo === tripProof.dispatchNo && command.action === 'ARRANGE_DELIVERY' && command.status === 'ACTIVE' && command.stage === 'AFTER_LOADING' + && BigInt(version(command.recordVersion)) > BigInt(version(body.recordVersion)) && result?.dispatchNo === tripProof.dispatchNo && result.cargoHash === body.cargoHash + && result.deliveries?.length === preview.groups.length && new Set(result.deliveries.map(d => d.deliveryNo)).size === result.deliveries.length + && result.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size + && result.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key + && g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) && result.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId))) + && result.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && Number(o.packageCount) === result.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0))) + const snapshots = {} + for (const ref of result.deliveries) { + const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = groups.find(g => g.groupKey === ref.groupKey) + const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排回查客户目的地及明确费用') + const expectedFees = [...input.fees.map(feeIdentity), ...group.externalQuotes.filter(q => money(q.amount) > 0n).map(q => feeIdentity({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: q.currency, amount: q.amount }))] + io.check('普通派送保留本客户本目的地费用,无伪零、重复或原费覆盖回执', null, detail?.order?.dispatchNo === tripProof.dispatchNo && detail.order.deliveryNo === ref.deliveryNo && id(detail.order.customerId) === id(ref.customerId) + && detail.order.outboundNo === ref.outboundNo && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId) + && (detail.feeCoverage == null || detail.feeCoverage.references?.length === 0) && Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length + && detail.fees.every(f => money(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED') + && stableRows(detail.fees.map(f => feeIdentity({ direction: f.feeDirection, feeType: f.feeType, currency: f.currency, amount: f.amount }))) === stableRows(expectedFees)) + snapshots[ref.deliveryNo] = { fees: clone(detail.fees), feeCoverage: clone(detail.feeCoverage) } + } + const again = await replay(io, `${path}/transfer-delivery`, body, command, '普通安排原键原载荷重放'); io.check('普通安排明确恢复原成功命令', null, again.replayed === true) + for (const ref of result.deliveries) { + const after = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排重放后核对不追加应收') + io.check('普通安排重放不增费不改覆盖快照', null, JSON.stringify({ fees: after.fees, feeCoverage: after.feeCoverage }) === JSON.stringify(snapshots[ref.deliveryNo])) + } + const after = await io.get(`${path}/delivery-arrangement`, '普通安排后回查报价与原费用候选未被改写') + const originalFacts = value => stableRows(value.groups.map(g => ({ groupKey: g.groupKey, externalQuotes: g.externalQuotes, feeSources: g.feeSources }))) + io.check('普通安排保持报价及原费用来源快照', null, originalFacts(after) === originalFacts(preview)) + Object.assign(proof.nativeArrangement, { status: 'VERIFIED', command, snapshots }) + return { confirmationRequest: body, deliveries: result.deliveries, outbounds: result.outbounds } +} + +/** 已确认实收来源只ADD_CARGO到原整车;复审、真实扫码、显式装车后一次安排全部客户。 + * options二选一:coverageCase为本轮PARTIAL;或arrangement={mode:'NATIVE',marker,dispatchNo, + * plannedDeliveryAt,deliveryMethod,pickupMethod,loadingType:'PALLET',appointmentAccount:'SELF', + * groups:[{customerId,destinationKey,fees:FeeItem[]}]}; 金额和费用覆盖均不自动推断。 + */ +export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase, arrangement } = {}) { + const receipt = tripProof?.externalReceipt, cases = context.createdResources?.fleetFeeCoverageCases + if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch) + throw Error('外接只接受本轮已运营确认且尚未装入本车的真实来货') + if (Boolean(coverageCase) === Boolean(arrangement)) throw Error('必须且只能选择同主单未扫码PARTIAL费用覆盖或普通NATIVE安排模式') + if (arrangement) validateNativeArrangement(context, tripProof, arrangement) + if (coverageCase && (!cases?.includes(coverageCase) || coverageCase.marker !== context.marker || coverageCase.mode !== 'PARTIAL' + || coverageCase.dispatchNo !== tripProof.dispatchNo || coverageCase.proof) || !pickerToken || typeof context.checkpoint !== 'function') + throw Error('外接加货前须有同主单未扫码PARTIAL用例及本轮PDA身份;必须先外接后扫码安排') + const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, arrangementMode: arrangement ? 'NATIVE' : 'FEE_COVERAGE', status: 'CHECKING', requests: [] } + const io = fleetConsumerIo(context, module, proof, `EXTERNAL-${tripProof.dispatchNo}`) + const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`, path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}` + const commandReplay = async (path, body, result, label) => { const again = await replay(io, path, body, result, label); io.check(`${label}明确恢复原结果`, null, again.replayed === true) } + try { + let current = await readDispatch(io, tripProof.dispatchNo) + io.check('只调整本轮同仓零扫码原主单', null, id(current.warehouseId) === id(tripProof.warehouseId) && current.data.status === 'ACTIVE' + && ['BEFORE_LOADING', 'LOADING'].includes(current.data.stage) && Number(current.data.counts.pickedPackageCount) === 0 && Number(current.data.counts.shippedPackageCount) === 0) + const ownItems = current.items.items.map(identity) + const before = await io.get(`${base}/capacity-plan`, '冻结外接预留与真实原容量计划') + io.check('原容量和外接承诺均完整', null, before?.planValid === true && before.capacityPlan?.pallets?.length > 0 && JSON.stringify(before.availableSegments) === JSON.stringify(receipt.finalSegments)) + let list = await io.get(`${base}/bookings`, '核对运营确认后的原booking和原报价') + const booking = list.records?.find(row => row.bookingNo === receipt.bookingNo), accepted = booking?.receipt + io.check('实时booking保持本轮客户仓实收和运营确认', null, booking?.status === 'RECEIVED' && !booking.dispatchNo && booking.incomingNo === receipt.incomingNo + && id(booking.customerId) === id(receipt.createRequest?.customerId) && accepted?.source?.warehouseId === id(tripProof.warehouseId) + && accepted.source.incomingNo === receipt.incomingNo && accepted.source.status === 'READY_FOR_DISPATCH' && accepted.source.receiptHash + && accepted.source.pallets?.length > 0 && Array.isArray(accepted.pallets) && accepted.operatorId && Number.isFinite(Date.parse(accepted.confirmedAt)) + && JSON.stringify(booking.cargo) === JSON.stringify(receipt.finalBooking.cargo) && String(booking.agreedAmount) === String(receipt.finalBooking.agreedAmount)) + const actual = accepted.source.pallets, packages = actual.reduce((n, p) => n + Number(p.packages), 0) + io.check('真实来货托盘和箱数守恒', null, new Set(actual.map(p => id(p.warehousePalletId))).size === actual.length && packages === Number(accepted.source.receivedPackages) + && actual.every(p => p.warehousePalletId && p.palletNo && Number.isSafeInteger(Number(p.packages)) && Number(p.packages) > 0) + && actual.some(p => id(p.warehousePalletId) === id(receipt.warehousePalletId))) + const page = await candidatePage(io, tripProof.warehouseId, { orderNo: receipt.incomingNo }) + const candidates = page.filter(c => c.inboundCode === receipt.incomingNo && id(c.customerId) === id(booking.customerId) + && id(c.warehouseId) === id(tripProof.warehouseId) && actual.some(p => id(p.warehousePalletId) === id(c.selection?.warehousePalletId))) + io.check('候选仅为本次确认的全部真实外接来源', null, candidates.length === actual.length && candidates.every(c => c.destination.key === booking.cargo.destinationKey + && actual.some(p => id(p.warehousePalletId) === id(c.selection.warehousePalletId) && p.palletNo === c.palletNo && Number(p.packages) === Number(c.availablePackages)))) + const addition = createBody(context.marker, `EXTERNAL-${tripProof.dispatchNo}`, candidates) + if (arrangement) { + const actualGroups = new Set([...current.items.items.map(i => groupIdentity(i.customerId, i.destination?.key)), ...candidates.map(c => groupIdentity(c.customerId, c.destination?.key))]) + io.check('普通安排配置在加货前精确覆盖全部实际客户目的地', null, stableRows([...actualGroups]) === stableRows(arrangement.groups.map(g => groupIdentity(g.customerId, g.destinationKey)))) + } + if (current.data.stage === 'LOADING') { + proof.returnRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('explicit-return'), reason: '本轮零扫码外接实收显式退回装车前,保留同一整车' } + const returned = await io.request('POST', `${path}/return-to-review`, proof.returnRequest, '零扫码明确退回审核') + await commandReplay(`${path}/return-to-review`, proof.returnRequest, returned, '退回审核原键重放') + current = await readDispatch(io, tripProof.dispatchNo) + } + io.check('同一主单已处于装车前可加货', null, current.data.stage === 'BEFORE_LOADING') + proof.addRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('add-real-incoming'), cargo: addition.cargo, batchAllocations: addition.batchAllocations } + const added = await io.request('POST', `${path}/items`, proof.addRequest, '真实已运营确认来源ADD_CARGO加入原主单') + io.check('加货命令保持原整车身份', null, added.dispatchNo === tripProof.dispatchNo && added.action === 'ADD_CARGO') + await commandReplay(`${path}/items`, proof.addRequest, added, '同主单加货原键重放') + current = await readDispatch(io, tripProof.dispatchNo) + io.check('加货和重放保留原客户物理行,仅增加本次完整来源', null, current.data.stage === 'BEFORE_LOADING' + && current.items.items.length === ownItems.length + candidates.length && ownItems.every(i => current.items.items.some(j => JSON.stringify(identity(j)) === JSON.stringify(i))) + && candidates.every(c => current.items.items.some(i => i.sourceKey === c.sourceKey && i.sourceVersion === c.sourceVersion && id(i.customerId) === id(booking.customerId) + && id(i.warehousePalletId) === id(c.selection.warehousePalletId) && i.inboundCode === receipt.incomingNo && Number(i.scheduledPackages) === Number(c.availablePackages) && Number(i.pickedPackages) === 0))) + list = await io.get(`${base}/bookings`, '加货后刷新booking与真实车次版本') + const latest = list.records.find(b => b.bookingNo === receipt.bookingNo) + proof.bindRequest = { tripRecordVersion: version(list.tripRecordVersion), recordVersion: version(latest.recordVersion), dispatchNo: tripProof.dispatchNo, idempotencyKey: io.key('bind-same-whole') } + const bound = await io.request('POST', `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}/dispatch`, proof.bindRequest, '原booking绑定本车唯一整车主单') + io.check('实收转关联只指向原主单', null, bound.booking?.status === 'BOUND' && bound.booking.dispatchNo === tripProof.dispatchNo && bound.booking.bookingNo === receipt.bookingNo + && JSON.stringify(bound.booking.cargo) === JSON.stringify(booking.cargo) && String(bound.booking.agreedAmount) === String(booking.agreedAmount)) + await commandReplay(`${base}/bookings/${encodeURIComponent(receipt.bookingNo)}/dispatch`, proof.bindRequest, bound, '同主单booking关联原键重放') + const after = await io.get(`${base}/capacity-plan`, '真实加货和预留转关联后重新核对容量') + io.check('实际新增货物使旧容量计划失效且没有第二张客户排车', null, after.planValid === false + && after.cargo.every(c => c.dispatchNo === tripProof.dispatchNo) && candidates.every(c => after.cargo.some(i => id(i.warehousePalletId) === id(c.selection.warehousePalletId) && Number(i.packages) === Number(c.availablePackages)))) + const pallets = new Map(before.capacityPlan.pallets.map(p => [id(p.warehousePalletId), clone(p)])) + for (const p of actual) { + const measurement = accepted.pallets.find(m => id(m.warehousePalletId) === id(p.warehousePalletId)), index = current.route.stops.findIndex(s => s.destinationKey === booking.cargo.destinationKey) + io.check('新托仅使用实际运营确认托位和来货实测', null, measurement && Number.isSafeInteger(measurement.palletSpaces) && measurement.palletSpaces > 0 + && Number(measurement.weightKg ?? p.weightKg) > 0 && Number(measurement.volumeCbm ?? p.volumeCbm) > 0 && index > 0) + pallets.set(id(p.warehousePalletId), { warehousePalletId: id(p.warehousePalletId), palletSpaces: measurement.palletSpaces, + weightKg: measurement.weightKg ?? p.weightKg, volumeCbm: measurement.volumeCbm ?? p.volumeCbm, deliveryIndex: index }) + } + io.check('新容量逐托覆盖全部实际货物且没有额外资源', null, new Set(after.cargo.map(c => id(c.warehousePalletId))).size === pallets.size && after.cargo.every(c => pallets.has(id(c.warehousePalletId)))) + proof.capacityRequest = { recordVersion: version(after.recordVersion), manifestHash: after.manifestHash, pallets: [...pallets.values()], reason: '本轮同主单外接实收按实际运营确认和原计划量重新核对' } + const planned = await io.request('POST', `${base}/capacity-plan`, proof.capacityRequest, '重新确认实际货物容量') + io.check('预留转实际后分段占用守恒', null, planned.planValid === true && JSON.stringify(planned.availableSegments) === JSON.stringify(before.availableSegments)) + const trip = await io.get(base, '复审前核对资源和唯一整车关联') + io.check('原车辆司机起运仓和唯一排车保持不变', null, trip.dispatches?.length === 1 && trip.dispatches[0].dispatchNo === tripProof.dispatchNo + && id(trip.trip?.warehouseId) === id(tripProof.warehouseId) && id(trip.trip.vehicleId) === id(tripProof.vehicleId) && id(trip.trip.driverId) === id(tripProof.driverId)) + tripProof.stops = trip.trip.stops + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) { + const detail = await io.get(path, `${action}前刷新原主单版本`) + const body = { recordVersion: version(detail.recordVersion), idempotencyKey: io.key(action), ...(action === 'submit-review' ? { remark: '本轮加入外接实物后全车重新提交' } : { decision: 'APPROVED', reason: '本轮外接实收全车重新审核' }) } + const result = await io.request('POST', `${path}/${action}`, body, `整车${action}`) + await commandReplay(`${path}/${action}`, body, result, `整车${action}原键重放`) + } + const ready = await io.get(path, '核对两级审核恢复真实扫码资格') + io.check('同主单两级审核通过且仍零扫码', null, ready.stage === 'LOADING' && ready.reviewState === 'APPROVED' && ready.reviews?.dispatchReview?.status === 'APPROVED' + && ready.reviews?.loadingReview?.status === 'APPROVED' && Number(ready.counts.pickedPackageCount) === 0) + const execution = arrangement ? await arrangeNative(context, module, io, proof, tripProof, arrangement, pickerToken, actualMeasurements) + : await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof }) + const groups = await io.get(`${path}/delivery-arrangement`, '安排后回查原生外接报价快照') + const quotes = groups.groups.flatMap(g => g.externalQuotes), expected = receipt.finalBooking + const matching = quotes.filter(q => q.bookingNo === receipt.bookingNo && q.inboundCode === receipt.incomingNo && id(q.customerId) === id(booking.customerId)) + io.check('原外接报价属于本次客户和来源且等于运营确认约定金额', null, matching.length === 1 && money(matching[0].amount) === money(expected.agreedAmount) + && matching[0].currency === expected.cargo.currency && matching[0].sourceHash && money(expected.agreedAmount) > 0n) + const deliveries = execution.deliveries.filter(d => groups.groups.some(g => g.groupKey === d.groupKey && g.externalQuotes.some(q => q.bookingNo === receipt.bookingNo))) + io.check('本次外接来源只落入其客户目的地派送', null, deliveries.length === 1 && id(deliveries[0].customerId) === id(booking.customerId)) + proof.outboundNo = deliveries[0].outboundNo; proof.deliveryNo = deliveries[0].deliveryNo; proof.deliveries = execution.deliveries + const delivery = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(id(booking.customerId))}`, '回查本客户外接应收来源') + const sources = await io.get(`/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: id(booking.customerId), businessType: 'DELIVERY_ORDER', businessNo: proof.deliveryNo })}`, '按本次客户派送业务号回查外接应收来源') + const fees = Array.isArray(sources) ? sources.filter(f => f.direction === 'RECEIVABLE' && f.feeItemCode === 'DELIVERY_FEE' && f.sourceStatus === 'ACTIVE' && f.inboundCode === receipt.incomingNo) : [] + const quantity = expected.cargo.priceBasis === 'PALLET' ? expected.cargo.palletSpaces : expected.cargo.priceBasis === 'ORDER' ? 1 : null + io.check('外接只登记本次约定金额应收且来源已审核', null, fees.length === 1 && money(fees[0].originalAmount) === money(expected.agreedAmount) + && fees[0].originalCurrency === expected.cargo.currency && fees[0].reviewStatus === 'APPROVED' && id(fees[0].customerId) === id(booking.customerId) + && quantity != null && Number(fees[0].chargeableQuantity) === Number(quantity) && money(fees[0].unitPrice) === money(expected.cargo.unitPrice) + && fees[0].quantityUnit === (expected.cargo.priceBasis === 'PALLET' ? '托位' : '单') + && fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id))) + proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.transferRequest = execution.confirmationRequest + proof.finalTripVersion = (await io.get(base, '交接同车资源版本')).trip.recordVersion + proof.financeAcknowledgement = 'NOT_VERIFIED'; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof + } catch (error) { proof.status = proof.outcomeUnknown || context.createdResources.dispatchWriteBoundary?.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error } } diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs index 53d296077..1ad89c9f2 100644 --- a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs @@ -1,108 +1,122 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' +import { readFileSync } from 'node:fs' +import { verifyFleetFeeCoverage, coveragePlan } from './fleet-fee-coverage-acceptance.mjs' -function fixture(mode = 'FULL', fault = '') { - const source = { marker: 'CURRENT-TEST', mode, dispatchNo: 'D-CURRENT', customerId: '900719925474099301', - transferBody: { loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [ - { direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' }, - ] } } - const cargo = [1, 2].map(n => ({ dispatchItemId: `item-${n}`, warehousePalletId: `pallet-${n}`, orderId: `order-${n}`, - sourceRegion: 'FIRSTLEG', sourceInboundId: `90071992547409940${n}`, inboundCode: `IN-${n}`, packages: 2 })) - const fees = cargo.map((row, index) => ({ financeFeeId: `90071992547409950${index}`, sourceVersion: 1, sourceHash: `fee-hash-${index}`, - sourceInboundId: row.sourceInboundId, customerId: source.customerId, sourceType: 'INBOUND_FEE', currency: 'CNY', amount: 200, status: 'AVAILABLE' })) - if (fault === 'foreign-customer') fees[0].customerId = 'different-customer' - if (fault === 'missing-source') fees.splice(1) - const hash = 'a'.repeat(64), calls = [], assertions = [] - let savedBody, replayed = false, dirtyOutbound = false - const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data }, requestId: 'req-test', traceId: 'trace-test' }) - const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: [source] }, - assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) }, - async request(method, path, body) { - calls.push({ method, path, body }) - if (method === 'GET' && path === '/api/platform/v1/last-leg/dispatches/D-CURRENT') - return response({ dispatchNo: source.dispatchNo, pickedPackageCount: 0, shippedPackageCount: 0, - items: cargo.map((row, index) => ({ id: row.dispatchItemId, packageCount: row.packages, - destinationWarehouseCode: `FBA-${index + 1}`, fbaWarehouseCode: `ORIGINAL-FBA-${index + 1}` })) }) - if (method === 'GET' && path.endsWith('/fee-coverage')) return response({ dispatchNo: source.dispatchNo, sourceComplete: true, - cargoHash: hash, cargo, feeSources: fees.map(row => fault === 'mutated-original' && savedBody ? { ...row, amount: 201 } : row) }) - if (method === 'GET' && path.includes('/outbound-orders?')) { - const params = new URL(path, 'https://test.invalid').searchParams - assert.equal(params.get('customerId'), source.customerId) - assert.equal(params.has('dispatchNo'), false, 'outbound list does not support dispatchNo filtering') - const rows = dirtyOutbound ? [{ outboundNo: 'OUT-WRONG', dispatchNo: source.dispatchNo }] : [{ outboundNo: 'OUT-OTHER', dispatchNo: 'DS-OTHER' }] - return response({ records: rows, total: rows.length, page: 1, size: 200 }) +const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', customerId = '900719925474099301' +const clone = value => structuredClone(value) +function fixture(mode = 'FULL', fault = '', needsLoading = false) { + const cases = ['FULL', 'PARTIAL'].map((mode, index) => ({ marker: 'CURRENT-TEST', mode, dispatchNo: `DS-${index + 1}`, customerId, + transferBody: { plannedDeliveryAt: '2026-10-22T01:00:00Z', loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' }] } })) + const source = cases.find(c => c.mode === mode), calls = [], assertions = [], ledger = new Map(), saved = new Map() + const states = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, recordVersion: 0, stage: needsLoading && c === source ? 'LOADING' : 'AFTER_LOADING', items: [1, 2].map(i => ({ + dispatchItemId: `${n + 1}1${i}`, sourceKey: `PLAN:${n + 1}2${i}`, sourceVersion: 'a'.repeat(64), customerId, warehousePalletId: `${n + 1}3${i}`, planPalletId: `${n + 1}2${i}`, + inboundCode: `IN-${n + 1}-${i}`, destination: { key: `FBA:A${i}`, warehouseCode: `A${i}` }, scheduledPackages: 2, pickedPackages: needsLoading && c === source ? 0 : 2 })) })) + if (fault === 'overlap') states[1].items[0].warehousePalletId = states[0].items[0].warehousePalletId + const triples = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, tripNo: `TR-${n + 1}`, warehouseId: '92', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }] })) + let dirty = false, replayed = false + const totals = s => ({ itemCount: s.items.length, palletCount: s.items.length, packageCount: 4, pickedPackageCount: s.items.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 }) + const detail = s => ({ dispatchNo: s.dispatchNo, status: 'ACTIVE', stage: s.stage, recordVersion: s.recordVersion, counts: totals(s), pickingReconciled: s.items.every(i => i.pickedPackages === 2), + dispatchInfo: { route: 'A1,A2' }, orderDetails: s.items.map(i => ({ customerId, sourceKey: i.sourceKey, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) + const cargo = s => s.items.map((i, n) => ({ ...i, orderId: `4${s.dispatchNo.slice(-1)}${n + 1}`, sourceRegion: 'FIRSTLEG', sourceInboundId: `9007199254740994${s.dispatchNo.slice(-1)}${n + 1}`, packages: 2 })) + const groups = s => cargo(s).map((row, n) => ({ groupKey: `G-${s.dispatchNo}-${n}`, customerId, destination: row.destination, groupCargoHash: `${n + 1}`.repeat(64), externalFreightHash: 'b'.repeat(64), externalQuotes: [], feeCoverageCargoHash: `${n + 1}`.repeat(64), cargo: [row], blockReasons: [], requiredServices: ['DELIVERY_FEE'], + feeSources: fault === 'missing-source' && n === 1 ? [] : [{ financeFeeId: `9007199254740995${s.dispatchNo.slice(-1)}${n}`, customerId: fault === 'foreign-customer' && n === 0 ? '999' : customerId, + currency: 'CNY', amount: fault === 'mutated-original' && saved.has(s.dispatchNo) ? 201 : 200, sourceVersion: 1, sourceHash: `fee-hash-${n}`, status: 'AVAILABLE', services: ['DELIVERY_FEE'], sourceKeys: [row.sourceKey] }] })) + const ack = (s, action, result = {}) => ({ dispatchNo: s.dispatchNo, action, recordVersion: s.recordVersion, status: 'ACTIVE', stage: s.stage, counts: totals(s), result, replayed: false }) + const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: cases, fleetTrips: triples }, checkpoint: async () => {}, + assert(_module, name, _response, passed) { assertions.push({ name, passed }) }, addStep(_module, name, _response, passed) { assertions.push({ name, passed }) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, + async request(method, raw, body, token) { + calls.push({ method, path: raw, body: clone(body), token }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), number = normalized.slice(BASE.length + 1).split('/')[0] + const state = states.find(s => s.dispatchNo === number), commandKey = `${normalized}:${body?.idempotencyKey}` + if (method === 'POST' && ledger.has(commandKey)) { assert.deepEqual(body, ledger.get(commandKey).body); replayed = true; return response({ ...clone(ledger.get(commandKey).data), replayed: true }) } + const remember = data => { ledger.set(commandKey, { body: clone(body), data: clone(data) }); return response(data) } + if (method === 'GET' && path === BASE) return response({ records: states.filter(s => s.dispatchNo === params.get('dispatchNo')).map(s => ({ ...detail(s), warehouseId: '92' })) }) + if (path.includes('/outbound-orders?') || raw.includes('/outbound-orders?')) return response({ records: dirty ? [{ outboundNo: 'OUT-DIRTY', dispatchNo: source.dispatchNo }] : [], total: dirty ? 1 : 0, page: 1, size: 200 }) + if (path.match(/\/outbound-orders\/OUT-DS-[12]$/)) { const n = path.slice(path.lastIndexOf('OUT-') + 4), s = states.find(s => s.dispatchNo === n), p = saved.get(n); return response({ outbound: { outboundNo: `OUT-${n}`, dispatchNo: n, status: 'WAIT_OUTBOUND' }, deliveries: p.groups.map((g, i) => ({ deliveryNo: `DEL-${n}-${i}`, status: 'WAIT_DELIVERY' })) }) } + const delivery = path.match(/\/delivery-orders\/DEL-(DS-[12])-(\d)$/) + if (delivery) { + assert.equal(params.get('customerId'), customerId) + const s = states.find(s => s.dispatchNo === delivery[1]), index = Number(delivery[2]), group = groups(s)[index], input = saved.get(s.dispatchNo).groups[index] + const references = input.feeCoverage.map(selection => ({ source: { ...group.feeSources.find(f => f.financeFeeId === selection.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId, + ...(fault === 'snapshot-source-amount' ? { amount: 999 } : {}) }, services: selection.services })) + const evidence = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: fault === 'wrong-cargo' ? cargo(s) : group.cargo, + references: fault === 'missing-reference' ? [] : references, operatorId: 'OP', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-19T02:00:00Z' : '2026-10-19T01:00:00Z' } : null + let fees = input.fees.map(f => ({ id: `8${index}`, feeDirection: f.direction, feeType: f.feeType, currency: f.currency, amount: f.amount, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' })) + if (fault === 'duplicate-new-fee' && index === 0 || fault === 'replay-adds-fee' && replayed) fees.push({ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }) + if (fault === 'charge-covered-destination') fees = index === 0 ? [{ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }] : [] + return response({ order: { dispatchNo: s.dispatchNo, deliveryNo: `DEL-${s.dispatchNo}-${index}`, customerId: fault === 'wrong-delivery-customer' ? '999' : customerId, outboundNo: `OUT-${s.dispatchNo}`, destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage: evidence }) } - if (method === 'GET' && path === '/api/platform/v1/last-leg/outbound-orders/OUT-CURRENT') return response({ - outbound: { outboundNo: 'OUT-CURRENT', dispatchNo: source.dispatchNo, status: 'WAIT_OUTBOUND' }, - deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY' })), - }) - if (method === 'GET' && path.endsWith('/external-freight')) return response({ snapshotHash: 'b'.repeat(64), quotes: [], requiresDeliveryFee: true }) + assert.ok(state, `${method} ${raw}`) + if (path.startsWith(PDA) && method === 'GET' && normalized.endsWith(number)) return response({ ...detail(state), items: state.items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: 2 }] })) }) + if (method === 'POST' && normalized.endsWith('/pick')) { assert.ok(token); if (body.recordVersion !== state.recordVersion) return response(null, '公共版本已变化'); const itemId = normalized.split('/').at(-2); state.items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; state.recordVersion++; return remember(ack(state, 'PICK')) } + if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...detail(state), manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: state.items.map((i, n) => ({ ...i, palletNo: `PAL-${n}`, deliveryIndex: n + 1, palletSpaces: 1, weightKg: 20, volumeCbm: 0.2, + evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map(field => ({ field, value: field === 'palletSpaces' ? 1 : field === 'weightKg' ? 20 : 0.2, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) })) }) + if (method === 'POST' && normalized.endsWith('/loading-complete')) { assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.items.every(i => i.pickedPackages === 2), true); state.stage = 'AFTER_LOADING'; state.recordVersion++; return remember(ack(state, 'LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + if (method === 'GET' && path.endsWith('/items')) return response({ dispatchNo: number, recordVersion: state.recordVersion, status: 'ACTIVE', stage: state.stage, items: state.items }) + if (method === 'GET' && path.endsWith('/route')) return response({ dispatchNo: number, recordVersion: state.recordVersion, stops: triples.find(t => t.dispatchNo === number).stops }) + if (method === 'GET' && path.endsWith('/delivery-arrangement')) return response({ ...detail(state), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), arrange: { allowed: !saved.has(number) }, groups: groups(state), arrangement: saved.has(number) ? { dispatchNo: number } : null }) if (method === 'POST' && path.endsWith('/transfer-delivery')) { - if (body.feeCoverageCargoHash !== hash) { - if (fault === 'failed-command-writes') dirtyOutbound = true - return response(null, '原费用对应货物已变化,请重新核对覆盖范围') - } - if (body.feeCoverage[0].sourceVersion !== 1) return response(null, '原费用版本已变化,请重新确认服务覆盖范围') - if (mode === 'FULL' && body.fees.length) return response(null, '本次货物的该服务已由原费用覆盖,请勿重复登记应收') - if (mode === 'PARTIAL' && !body.fees.length) return response(null, '尚未有约定运费的货物须填写派送费') - replayed = Boolean(savedBody) - if (savedBody) assert.deepEqual(body, savedBody) - savedBody = structuredClone(body) - return response({ dispatchNo: source.dispatchNo, outboundNo: 'OUT-CURRENT', outboundStatus: 'WAIT_OUTBOUND', replayed, - deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, deliveryStatus: 'WAIT_DELIVERY' })) }) + assert.ok(body.idempotencyKey); assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.stage, 'AFTER_LOADING') + if (body.cargoHash !== 'a'.repeat(64)) { if (fault === 'failed-command-writes') dirty = true; return response(null, '装车或实际货物已经变化') } + if (body.groups.some(g => g.feeCoverage.some(f => f.sourceVersion !== 1))) return response(null, '原费用版本已变化') + if (source.mode === 'FULL' && body.groups.some(g => g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收') + if (source.mode === 'PARTIAL' && !body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对') + saved.set(number, clone(body)); state.recordVersion++ + const result = ack(state, 'ARRANGE_DELIVERY', { dispatchNo: number, cargoHash: body.cargoHash, outbounds: [{ customerId, outboundNo: `OUT-${number}`, status: 'WAIT_OUTBOUND', packageCount: 4 }], + deliveries: groups(state).map((g, n) => ({ groupKey: g.groupKey, customerId, destination: g.destination, outboundNo: `OUT-${number}`, deliveryNo: `DEL-${number}-${n}`, status: 'WAIT_DELIVERY', packageCount: 2 })) }) + if (fault === 'unknown-write') return { http: 0, body: null, raw: 'connection reset' } + return remember(result) } - const detail = path.match(/^\/api\/platform\/v1\/last-leg\/delivery-orders\/DEL-([12])\?customerId=900719925474099301$/) - assert.ok(detail, `Unexpected ${method} ${path}`) - const index = Number(detail[1]) - 1 - const selected = savedBody.feeCoverage.filter(selection => selection.financeFeeId === fees[index].financeFeeId) - const references = selected.map(selection => ({ source: fees[index], services: selection.services })) - const shouldCharge = mode === 'PARTIAL' && (fault === 'charge-covered-destination' ? index === 0 : index === 1) - const newFee = { feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, sourceStatus: 'ACTIVE' } - return response({ order: { dispatchNo: source.dispatchNo, deliveryNo: `DEL-${index + 1}`, destinationWarehouseCode: `FBA-${index + 1}` }, - fees: shouldCharge || (fault === 'duplicate-new-fee' && index === 0) || (fault === 'replay-adds-fee' && replayed) ? [newFee] : [], - feeCoverage: { cargoHash: hash, operatorId: 'op-test', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-03T02:00:00Z' : '2026-10-03T01:00:00Z', - cargo: fault === 'wrong-cargo' ? [cargo[0]] : [cargo[index]], references: fault === 'missing-reference' ? [] : references } }) - }, - } - return { source, context, calls, assertions, clearFault() { fault = '' } } + assert.equal(method, 'GET', raw); assert.equal(path, `${BASE}/${number}`); return response(detail(state)) + } } + return { context, source, cases, calls, assertions, states, groups, clearFault() { fault = '' } } } -test('persisted reconciliation reads exact original documents and only replays the original successful command', async () => { - const f = fixture('FULL', 'wrong-cargo') - await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source)) - assert.equal(f.source.proof.outboundNo, 'OUT-CURRENT') - const originalKey = f.source.proof.confirmationRequest.idempotencyKey - const before = f.calls.length - f.clearFault() - const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true }) - assert.equal(proof.status, 'VERIFIED') - const commands = f.calls.slice(before).filter(row => row.method === 'POST') - assert.ok(commands.length > 0) - assert.ok(commands.every(row => row.body.idempotencyKey === originalKey)) - assert.ok(f.calls.slice(before).some(row => row.method === 'GET' && row.path.endsWith('/outbound-orders/OUT-CURRENT'))) +for (const mode of ['FULL', 'PARTIAL']) test(`${mode} uses native grouped fees, immutable confirmation evidence and original-key replay`, async () => { + const f = fixture(mode), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5); assert.equal(proof.deliveryNos.length, 2) + assert.ok(f.assertions.every(a => a.passed)); assert.ok(f.calls.every(c => !/cancel|unbind|pre-arrange|fee-coverage/.test(c.path))) + assert.equal(proof.confirmationRequest.recordVersion, 0); assert.equal(proof.confirmationRequest.groups.length, 2) + await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/) }) -for (const mode of ['FULL', 'PARTIAL']) { - test(`${mode} fee coverage helper checks source scope, pre-write conflicts, each destination and replay`, async () => { - const f = fixture(mode) - const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source) - assert.equal(proof.status, 'VERIFIED') - assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5) - assert.deepEqual(proof.deliveryNos, ['DEL-1', 'DEL-2']) - assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) - assert.ok(f.calls.every(call => !/cancel|receipt|departure/.test(call.path))) - await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/) - }) -} +test('LOADING performs actual PDA picks, immutable pick replay and explicit loading completion before arranging', async () => { + const f = fixture('FULL', '', true), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { pickerToken: 'OFFLINE-ONLY' }) + assert.equal(proof.status, 'VERIFIED') + const loading = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/loading-complete')), arrangement = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')) + assert.ok(loading > f.calls.findIndex(c => c.path.endsWith('/pick'))); assert.ok(arrangement > loading) +}) -for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference', - 'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee']) { - test(`fee coverage HTTP proof cannot pass ${fault}`, async () => { - const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault) - await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source)) - assert.equal(f.source.proof.status, 'CHECKING') - assert.ok(f.assertions.some(row => !row.passed)) - if (['foreign-customer', 'missing-source'].includes(fault)) assert.equal(f.calls.some(row => row.method === 'POST'), false) - }) -} +test('reviewed persisted reconciliation only replays original successful native command after actual document readback', async () => { + const f = fixture('FULL', 'wrong-cargo'); await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.ok(f.source.proof.outboundNo) + const original = clone(f.source.proof.confirmationRequest), before = f.calls.length; f.clearFault() + assert.equal((await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true })).status, 'VERIFIED') + assert.ok(f.calls.slice(before).filter(c => c.method === 'POST').every(c => JSON.stringify(c.body) === JSON.stringify(original))) +}) + +for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference', 'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee', 'wrong-delivery-customer', 'overlap', 'unknown-write', 'snapshot-source-amount']) test(`native fee proof rejects ${fault}`, async () => { + const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault) + await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.notEqual(f.source.proof.status, 'VERIFIED') + if (['foreign-customer', 'missing-source', 'overlap'].includes(fault)) assert.equal(f.calls.some(c => c.method === 'POST'), false) + if (fault === 'unknown-write') { assert.equal(f.source.proof.status, 'OUTCOME_UNKNOWN'); const last = f.calls.at(-1); assert.equal(last.method, 'POST'); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true) } +}) + +test('two current registered source groups are required before HTTP; missing PDA and ambiguous mixed provenance never guess', async () => { + const missing = fixture(); missing.context.createdResources.fleetFeeCoverageCases.pop(); await assert.rejects(verifyFleetFeeCoverage(missing.context, 'coverage', missing.source), /两组不重叠/); assert.equal(missing.calls.length, 0) + const noPicker = fixture('FULL', '', true); await assert.rejects(verifyFleetFeeCoverage(noPicker.context, 'coverage', noPicker.source), /PDA身份/); assert.equal(noPicker.calls.some(c => c.method === 'POST'), false) + const f = fixture('PARTIAL'), groups = f.groups(f.states[1]); groups[0].cargo.push({ ...groups[0].cargo[0], sourceInboundId: '999' }); assert.throws(() => coveragePlan({ groups }, f.source), /不能唯一映射/) + const multi = fixture('PARTIAL'), many = multi.groups(multi.states[1]), third = clone(many[1]); third.groupKey = 'GROUP-THIRD'; third.cargo[0].sourceKey = 'PLAN:999'; third.cargo[0].sourceInboundId = '999'; third.feeSources[0].financeFeeId = '9999'; third.feeSources[0].sourceKeys = ['PLAN:999']; many.push(third) + assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/) + multi.source.transferBody.groupFees = { [many[1].groupKey]: [{ ...multi.source.transferBody.fees[0], amount: 6 }] } + assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/) + multi.source.transferBody.groupFees[third.groupKey] = [{ ...multi.source.transferBody.fees[0], amount: 7 }] + assert.deepEqual(coveragePlan({ groups: many }, multi.source).groups.map(g => g.fees.map(f => f.amount)), [[], [6], [7]]) + const unsafe = fixture(); unsafe.source.customerId = 9007199254740992; assert.throws(() => coveragePlan({ groups: unsafe.groups(unsafe.states[0]) }, unsafe.source), /ID精度无效/) +}) + +test('public native delivery DTO retains grouped fees and requires version and loading hashes', () => { + const dto = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java', import.meta.url), 'utf8') + assert.ok(dto.includes('record ArrangeDelivery(Long recordVersion')); assert.ok(dto.includes('String loadingCompletionHash')); assert.ok(dto.includes('List groups')) +}) diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs index eec10d48c..b678c5302 100644 --- a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs @@ -1,167 +1,214 @@ -import { randomUUID } from 'node:crypto' +import { DISPATCH_BASE as BASE, id as stringId, version, commandKey, dispatchIo, replay, readDispatch } from './grouped-dispatch-acceptance.mjs' +import { pickAll } from './phase5-overseas-fulfillment.mjs' +import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs' -const OPTIONS = { scenario: 'fleet-original-fee-coverage', deploymentBlocker: true } -const id = value => value == null ? '' : String(value) -const ok = response => response?.http === 200 && id(response?.body?.code) === '200' -const businessConflict = response => response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409') -const canonical = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort()) -const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), - orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) }) -const feeIdentity = row => ({ financeFeeId: id(row.financeFeeId), sourceVersion: Number(row.sourceVersion), sourceHash: row.sourceHash, - sourceInboundId: id(row.sourceInboundId), customerId: id(row.customerId), currency: row.currency, amount: Number(row.amount), status: row.status }) +export const stableRows = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort()) +const clone = value => structuredClone(value) +const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) } +const positive = value => /^[1-9]\d*$/.test(id(value)) +const sha = value => /^[a-f0-9]{64}$/i.test(value || '') +const cents = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('费用必须为明确的非负两位金额'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) } +const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) }) +const feeIdentity = fee => ({ financeFeeId: id(fee.financeFeeId), customerId: id(fee.customerId), currency: fee.currency, amount: String(fee.amount), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, status: fee.status, sourceKeys: [...fee.sourceKeys].sort() }) -/** Dedicated current-run dispatches only: one FULL case and a separate PARTIAL case with >=2 actual inbound sources. */ -export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false } = {}) { - if (!context.createdResources?.fleetFeeCoverageCases?.includes(source) || source.marker !== context.marker || (source.proof && !reconcilePersisted) - || !['FULL', 'PARTIAL'].includes(source.mode) || !source.dispatchNo || !source.customerId) - throw new Error('原费用验收需要本轮专用、未执行的排车用例;失败后保留原请求,不重复创建') - if (reconcilePersisted && (source.mode !== 'FULL' || source.proof?.status !== 'CHECKING' - || !source.proof.outboundNo || !source.proof.deliveryNos?.length || !source.proof.confirmationRequest?.idempotencyKey)) - throw new Error('已落单核对须有原成功请求、出库单及派送集合,不允许重造') - const proof = reconcilePersisted ? source.proof : source.proof = { status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] } - const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(source.dispatchNo)}` - const outboundPath = `/api/platform/v1/last-leg/outbound-orders?size=200&customerId=${encodeURIComponent(source.customerId)}` - async function check(name, passed, response) { - context.assert(module, name, response, Boolean(passed), '', OPTIONS) - if (!passed) throw new Error(`${module}: ${name}`) - } - async function request(method, path, body, label, expectedMessage) { - const response = await context.request(method, path, body) - proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code, - requestId: response?.requestId, traceId: response?.traceId }) - await check(label, Boolean(response?.requestId) && Boolean(response?.traceId) - && (expectedMessage ? businessConflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response) +export function fleetConsumerIo(context, module, proof, namespace) { + const io = dispatchIo(context, module, proof, namespace) + const request = async (method, path, body, label, expected) => { + const response = await io.send(method, path, body, label) + io.check(`${label}诊断回执完整`, response, Boolean(response?.requestId) && Boolean(response?.traceId)) + io.check(label, response, expected ? [200, 409].includes(response.http) && String(response.body?.code) === 'A0409' && expected.test(String(response.body?.message)) + : response.http === 200 && String(response.body?.code) === '200') return response.body?.data } - async function noOutbound(label) { - const rows = [] - let total - for (let index = 1; ; index++) { - const page = await request('GET', `${outboundPath}&page=${index}`, undefined, label) - await check('客户出库列表分页元数据完整', Array.isArray(page?.records) && Number.isSafeInteger(Number(page.total)) - && Number(page.total) >= 0 && Number(page.page) === index && Number(page.size) === 200) - total ??= Number(page.total) - await check('客户出库列表跨页范围稳定', total === Number(page.total) && (page.records.length > 0 || rows.length === total)) - rows.push(...page.records) - if (rows.length >= total) break + return { ...io, request, get: (path, label) => request('GET', path, undefined, label) } +} + +export async function readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements } = {}) { + io.check('真实履约写入须有正式持久化检查点', null, typeof context.checkpoint === 'function') + let current = await readDispatch(io, trip.dispatchNo) + io.check('本轮实车仓库及未发车范围', null, context.createdResources.fleetTrips?.includes(trip) && trip.tripNo + && current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && Number(current.data.counts.shippedPackageCount) === 0) + if (current.data.stage === 'LOADING') { + io.check('真实扫码需本轮PDA身份', null, typeof pickerToken === 'string' && pickerToken.length > 0) + io.check('PDA逐来源真实扫码完成', null, await pickAll(context, pickerToken, trip.dispatchNo)) + await confirmFleetLoading(context, module, trip, pickerToken, actualMeasurements) + current = await readDispatch(io, trip.dispatchNo) + } + io.check('安排前已真实扫码并显式装车完成', null, current.data.stage === 'AFTER_LOADING' && current.data.pickingReconciled === true + && Number(current.data.counts.packageCount) > 0 && Number(current.data.counts.pickedPackageCount) === Number(current.data.counts.packageCount) + && current.items.items.every(i => Number(i.pickedPackages) === Number(i.scheduledPackages))) + return current +} + +// FeeCandidate未暴露sourceInboundId,仅在sourceKeys可唯一映射到实际原入仓时选择,混托歧义直接阻断。 +export function coveragePlan(preview, source, externalReceipt) { + if (!preview.groups?.length) throw Error('缺少当前全车派送分组') + const firstleg = preview.groups.flatMap(g => g.cargo.filter(c => c.sourceRegion === 'FIRSTLEG')) + const inboundIds = [...new Set(firstleg.map(c => id(c.sourceInboundId)))].sort() + if (!inboundIds.length || inboundIds.some(v => !positive(v)) || source.mode === 'PARTIAL' && inboundIds.length < 2) throw Error('FULL/PARTIAL需要本轮真实原入仓,PARTIAL至少两票') + const selectedInbounds = new Set(source.mode === 'FULL' ? inboundIds : [inboundIds[0]]) + const originalFees = [], selections = new Map(), uncovered = [] + for (const group of preview.groups) { + if (!group.groupKey || !positive(group.customerId) || !group.destination?.key || !sha(group.groupCargoHash) || !sha(group.feeCoverageCargoHash) + || !sha(group.externalFreightHash) || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.feeSources) || !Array.isArray(group.externalQuotes) + || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('客户目的地或费用预览范围不完整') + const own = group.cargo.filter(c => c.sourceRegion === 'FIRSTLEG') + if (own.some(c => id(c.customerId) !== id(source.customerId))) throw Error('原费用跨客户,不能沿其他客户确认覆盖') + if (group.externalQuotes.some(q => !externalReceipt || q.bookingNo !== externalReceipt.bookingNo || q.inboundCode !== externalReceipt.incomingNo + || id(q.customerId) !== id(group.customerId) || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash + || q.currency !== externalReceipt.finalBooking.cargo.currency || cents(q.amount) !== cents(externalReceipt.finalBooking.agreedAmount))) throw Error('当前外接报价与本轮运营确认金额或来源摘要不一致') + for (const cargo of group.cargo.filter(c => c.sourceRegion !== 'FIRSTLEG')) { + if (!externalReceipt || cargo.inboundCode !== externalReceipt.incomingNo || !externalReceipt.createRequest + || id(cargo.customerId) !== id(externalReceipt.createRequest.customerId) + || !group.externalQuotes.some(q => q.bookingNo === externalReceipt.bookingNo && q.inboundCode === cargo.inboundCode && id(q.customerId) === id(group.customerId))) throw Error('非头程货物没有本轮已确认外接报价') + } + const byInbound = new Map() + for (const fee of group.feeSources) { + if (id(fee.customerId) !== id(group.customerId) || !positive(fee.financeFeeId) || fee.currency !== 'CNY' || !['AVAILABLE', 'SETTLED'].includes(fee.status) + || !Number.isSafeInteger(fee.sourceVersion) || fee.sourceVersion < 1 || !fee.sourceHash || !Array.isArray(fee.sourceKeys) || !fee.sourceKeys.length + || !fee.services?.includes('DELIVERY_FEE')) throw Error('原费用客户、版本或来源不可用') + cents(fee.amount) + const actual = own.filter(c => fee.sourceKeys.includes(c.sourceKey)), ids = new Set(actual.map(c => id(c.sourceInboundId))) + if (ids.size !== 1 || fee.sourceKeys.some(k => !actual.some(c => c.sourceKey === k))) throw Error('原费用sourceKeys不能唯一映射原入仓;需生产预览提供原费用sourceInboundId或无混托真实来源') + const inbound = [...ids][0] + if (!byInbound.has(inbound)) byInbound.set(inbound, fee) + originalFees.push({ groupKey: group.groupKey, ...feeIdentity(fee) }) } - await check('失败命令没有生成本排车出库事实', rows.length === total - && new Set(rows.map(row => row.outboundNo)).size === rows.length && rows.every(row => row.dispatchNo !== source.dispatchNo)) + if (own.some(c => !byInbound.has(id(c.sourceInboundId)))) throw Error('本轮每票必须有实际原费用,不能借其他票补齐') + selections.set(group.groupKey, [...byInbound].filter(([inbound]) => selectedInbounds.has(inbound)).map(([, fee]) => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] }))) + if (own.some(c => !selectedInbounds.has(id(c.sourceInboundId)))) uncovered.push(group.groupKey) } - const dispatch = await request('GET', dispatchPath, undefined, '核对本轮费用验收排车来源') - await check('只处理本轮尚未拣货及发车的排车', dispatch?.dispatchNo === source.dispatchNo - && Number(dispatch.pickedPackageCount) === 0 - && Number(dispatch.shippedPackageCount) === 0 && dispatch.items?.length > 0) - if (!reconcilePersisted) await noOutbound('确认专用排车尚无出库单') - const preview = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '读取本次实际货物及原 Finance 费用来源') - await check('原费用范围完整且可确认', preview?.sourceComplete === true && !preview.blockReason - && preview.dispatchNo === source.dispatchNo && /^[0-9a-f]{64}$/.test(preview.cargoHash || '') - && preview.cargo?.length > 0 && preview.cargo.every(row => row.sourceRegion === 'FIRSTLEG' && id(row.sourceInboundId))) - const itemTotals = new Map() - for (const row of preview.cargo) itemTotals.set(id(row.dispatchItemId), (itemTotals.get(id(row.dispatchItemId)) || 0) + Number(row.packages)) - await check('费用预览逐条排车箱数核平', itemTotals.size === dispatch.items.length - && dispatch.items.every(row => itemTotals.get(id(row.id)) === Number(row.packageCount))) - const inboundIds = [...new Set(preview.cargo.map(row => id(row.sourceInboundId)))].sort() - await check('原费用均属于当前客户和实际原入仓', Array.isArray(preview.feeSources) && preview.feeSources.every(fee => - id(fee.customerId) === id(source.customerId) && inboundIds.includes(id(fee.sourceInboundId)) - && ['AVAILABLE', 'SETTLED'].includes(fee.status) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(fee.sourceType) - && fee.currency === 'CNY' && Number(fee.sourceVersion) >= 1 && fee.sourceHash && id(fee.financeFeeId))) - const sources = inboundIds.map(inboundId => preview.feeSources.find(fee => id(fee.sourceInboundId) === inboundId)) - await check('本轮每票已产生可引用原费用,不借其他票费用补齐', sources.every(Boolean) - && (source.mode === 'FULL' || inboundIds.length >= 2)) - const selectedFees = source.mode === 'FULL' ? sources : [sources[0]] - const selections = selectedFees.map(fee => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: Number(fee.sourceVersion), - sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] })) - const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '核对本专用排车没有外接报价混入覆盖用例') - await check('本用例为原费用货物且使用托盘派送自有预约', Array.isArray(freight?.quotes) && freight.quotes.length === 0 - && freight.requiresDeliveryFee === true && source.transferBody?.loadingType === 'PALLET' - && source.transferBody.appointmentAccount === 'SELF' && source.transferBody.fees?.length === 1 - && source.transferBody.fees[0].direction === 'RECEIVABLE' && source.transferBody.fees[0].feeType === 'DELIVERY_FEE' - && Number(source.transferBody.fees[0].amount) > 0) - const body = reconcilePersisted ? proof.confirmationRequest : { ...source.transferBody, idempotencyKey: `fleet-fee-${randomUUID()}`, - externalFreightHash: freight.snapshotHash, feeCoverageCargoHash: preview.cargoHash, feeCoverage: selections, - fees: source.mode === 'FULL' ? [] : source.transferBody.fees } - proof.confirmationRequest = body - if (!reconcilePersisted) proof.originalFees = preview.feeSources.map(feeIdentity) - const transferPath = `${dispatchPath}/transfer-delivery` - let transferred - if (reconcilePersisted) { - await check('原成功请求的货物与引用仍对应本次核对范围', body.feeCoverageCargoHash === preview.cargoHash - && canonical(body.feeCoverage) === canonical(selections)) - const stored = await request('GET', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, undefined, '先核对已持久化的原出库与派送集合') - await check('原出库和派送存在且尚未执行', stored?.outbound?.dispatchNo === source.dispatchNo - && stored.outbound.outboundNo === proof.outboundNo && stored.outbound.status === 'WAIT_OUTBOUND' - && canonical((stored.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos) - && stored.deliveries.every(row => row.status === 'WAIT_DELIVERY')) - transferred = await request('POST', transferPath, body, '确认原持久化单据后仅重放同键同载荷成功命令') - await check('核对重放未新建任何业务单据', transferred?.replayed === true && transferred.outboundNo === proof.outboundNo - && canonical((transferred.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)) - } else { - const staleHash = `${preview.cargoHash[0] === '0' ? '1' : '0'}${preview.cargoHash.slice(1)}` - await request('POST', transferPath, { ...body, idempotencyKey: `stale-cargo-${randomUUID()}`, feeCoverageCargoHash: staleHash }, - '货物摘要陈旧时拒绝转派送', /货物已变化/) - await noOutbound('陈旧货物拒绝后回查无业务写入') - await request('POST', transferPath, { ...body, idempotencyKey: `stale-fee-${randomUUID()}`, - feeCoverage: selections.map((row, index) => index === 0 ? { ...row, sourceVersion: row.sourceVersion + 1 } : row) }, - '原费用版本陈旧时拒绝确认', /原费用版本已变化/) - await noOutbound('陈旧原费用拒绝后回查无业务写入') - await request('POST', transferPath, { ...body, idempotencyKey: `fee-scope-${randomUUID()}`, - fees: source.mode === 'FULL' ? source.transferBody.fees : [] }, - source.mode === 'FULL' ? '全覆盖禁止再登记派送应收' : '部分覆盖仍须登记未覆盖派送费', - source.mode === 'FULL' ? /已由原费用覆盖/ : /须填写派送费/) - await noOutbound('覆盖金额校验拒绝后回查无业务写入') - transferred = await request('POST', transferPath, body, '正式保存本次原费用服务覆盖并转派送') + const template = source.transferBody + if (template?.loadingType !== 'PALLET' || template.appointmentAccount !== 'SELF' || template.fees?.length !== 1 + || template.fees[0].direction !== 'RECEIVABLE' || template.fees[0].feeType !== 'DELIVERY_FEE' || cents(template.fees[0].amount) <= 0n) throw Error('覆盖用例须有明确的未覆盖派送应收金额') + if (uncovered.length > 1 && uncovered.some(key => !Array.isArray(template.groupFees?.[key]) || template.groupFees[key].length !== 1)) throw Error('多个未覆盖目的地须由本轮用例明确逐组费用,不能自动分摊') + const groups = preview.groups.map(group => { + const fees = uncovered.includes(group.groupKey) ? clone(template.groupFees?.[group.groupKey] || template.fees) : [] + if (uncovered.includes(group.groupKey) && fees.length !== 1 || fees.some(f => f.direction !== 'RECEIVABLE' || f.feeType !== 'DELIVERY_FEE' || cents(f.amount) <= 0n || !['CNY', 'USD'].includes(f.currency) || f.currency !== template.fees[0].currency)) throw Error('逐组未覆盖应收不完整') + return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: template.plannedDeliveryAt || new Date(Date.now() + 21600000).toISOString(), + deliveryMethod: template.deliveryMethod || 'TRUCK_PALLET', waybillNo: template.waybillNo || null, carrierName: template.carrierName || null, + pickupMethod: template.pickupMethod || 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: template.vehicleType || 'TRUCK', vehicleSize: template.vehicleSize || null, + appointmentAccount: 'SELF', sellerName: null, remark: template.remark || null, externalFreightHash: group.externalFreightHash, + feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: selections.get(group.groupKey), fees } + }) + return { groups, originalFees, selectedInbounds: [...selectedInbounds], inboundIds } +} + +async function noDocuments(io, source, label) { + let total, rows = [] + for (let page = 1; ; page++) { + const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId: id(source.customerId), size: '200', page: String(page) })}`, label) + io.check('客户出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.page) === page && Number(data.size) === 200 + && Number(data.total) >= 0 && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total))) + total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break } - await check('转派送生成本次真实出库和各目的地派送', transferred?.dispatchNo === source.dispatchNo && transferred.outboundNo - && transferred.outboundStatus === 'WAIT_OUTBOUND' && transferred.deliveries?.length > 0 - && transferred.deliveries.every(row => row.deliveryNo && row.deliveryStatus === 'WAIT_DELIVERY')) - proof.outboundNo = transferred.outboundNo - proof.deliveryNos = transferred.deliveries.map(row => row.deliveryNo).sort() - const evidenceByDelivery = {} - const feesByDelivery = {} - let newReceivable = 0 - const allCargo = [] - for (const number of proof.deliveryNos) { - const detailPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}` - const detail = await request('GET', detailPath, undefined, '回查派送详情中的原费用确认快照') - const evidence = detail?.feeCoverage - const destination = id(detail?.order?.destinationWarehouseCode || detail?.order?.destinationWarehouseId) - const destinationItems = new Set(dispatch.items.filter(row => id(row.destinationWarehouseCode || row.fbaWarehouseCode) === destination).map(row => id(row.id))) - await check('费用确认货物只属于当前派送目的地', destination && destinationItems.size > 0 - && evidence?.cargo?.every(row => destinationItems.has(id(row.dispatchItemId)))) - await check('确认快照属于本次派送及实际原费用版本', detail?.order?.dispatchNo === source.dispatchNo - && detail.order.deliveryNo === number && evidence?.cargoHash === preview.cargoHash - && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) && evidence.cargo?.length > 0 - && Array.isArray(evidence.references) && evidence.references.every(ref => evidence.cargo.some(row => id(row.sourceInboundId) === id(ref.source?.sourceInboundId)) && selectedFees.some(fee => - id(fee.financeFeeId) === id(ref.source?.financeFeeId) && Number(fee.sourceVersion) === Number(ref.source?.sourceVersion) - && fee.sourceHash === ref.source?.sourceHash && canonical(ref.services || []) === canonical(['DELIVERY_FEE'])))) - const selectedInbounds = new Set(selectedFees.map(fee => id(fee.sourceInboundId))) - await check('快照保留本派送已选择的每票服务引用', evidence.cargo.every(row => !selectedInbounds.has(id(row.sourceInboundId)) - || evidence.references.some(ref => id(ref.source?.sourceInboundId) === id(row.sourceInboundId) && ref.services.includes('DELIVERY_FEE')))) - await check('费用明细为明确应收列表', Array.isArray(detail.fees) && detail.fees.every(row => row.feeDirection && row.feeType)) - const added = detail.fees.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE' && row.sourceStatus !== 'VOIDED') - await check('已全覆盖目的地不承担其他目的地的新增派送费', !evidence.cargo.every(row => selectedInbounds.has(id(row.sourceInboundId))) || added.length === 0) - await check('新增派送费没有伪零值或混合币种', added.every(row => Number(row.amount) > 0 && row.currency === source.transferBody.fees[0].currency)) - newReceivable += added.reduce((sum, row) => sum + Number(row.amount), 0) - allCargo.push(...evidence.cargo.map(cargoIdentity)) - evidenceByDelivery[number] = evidence - feesByDelivery[number] = canonical(detail.fees) - } - await check('多目的地快照汇总与实际原货物一致且不重复', canonical(allCargo) === canonical(preview.cargo.map(cargoIdentity))) - await check('仅未覆盖部分产生本次新增应收', Math.round(newReceivable * 100) - === (source.mode === 'FULL' ? 0 : Math.round(Number(source.transferBody.fees[0].amount) * 100))) - const replay = await request('POST', transferPath, body, '原费用确认成功后同键重放') - await check('重放保持同一出库和派送集合', replay?.replayed === true && replay.outboundNo === proof.outboundNo - && canonical((replay.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)) - for (const number of proof.deliveryNos) { - const detail = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`, undefined, '重放后回查覆盖证据') - await check('重放未改写确认人时间和原费用快照', JSON.stringify(detail.feeCoverage) === JSON.stringify(evidenceByDelivery[number])) - await check('成功命令重放未追加费用', Array.isArray(detail.fees) && canonical(detail.fees) === feesByDelivery[number]) - } - const after = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '回查引用原费用未改变其来源事实') - await check('原费用版本金额与状态保持不变', canonical(after.feeSources.map(feeIdentity)) === canonical(proof.originalFees)) - proof.snapshots = evidenceByDelivery - proof.newReceivable = newReceivable - proof.status = 'VERIFIED' - return proof + io.check('拒绝后无本排车出库写入', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== source.dispatchNo)) +} + +/** 两个本轮不重叠来源用例必须先准备;从不取消已扫码FULL再复用同批实物。 */ +export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false, pickerToken, actualMeasurements, externalTrip } = {}) { + const cases = context.createdResources?.fleetFeeCoverageCases + if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted) throw Error('原费用验收需要本轮专用、未执行的排车用例;保留原请求') + const pair = ['FULL', 'PARTIAL'].map(mode => cases.filter(c => c.marker === context.marker && c.mode === mode)) + if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮生产者预先准备FULL/PARTIAL两组不重叠真实排车,不能取消已扫码FULL复用同批实物') + if (reconcilePersisted && (!source.proof?.confirmationRequest || !source.proof?.outboundNo || !source.proof?.deliveryNos?.length)) throw Error('续验须有原成功命令和已持久化单据,不可重造') + const proof = reconcilePersisted ? source.proof : source.proof = { marker: context.marker, status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] } + const io = fleetConsumerIo(context, module, proof, `FEE-${source.mode}`), path = `${BASE}/${encodeURIComponent(source.dispatchNo)}` + try { + const scopes = [] + for (const [candidate] of pair) scopes.push(await readDispatch(io, candidate.dispatchNo)) + io.check('两组真实来源和实托均不重叠', null, scopes.every(s => s.items.items.length > 0) && scopes[0].items.items.every(i => !scopes[1].items.items.some(j => id(i.warehousePalletId) === id(j.warehousePalletId) || i.sourceKey === j.sourceKey))) + const trip = context.createdResources.fleetTrips?.find(t => t.dispatchNo === source.dispatchNo) + io.check('本排车有本轮正式车辆审核生产回执', null, Boolean(trip)) + const loaded = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements }) + const preview = await io.get(`${path}/delivery-arrangement`, '读取原生全车费用预览') + io.check('原生派送门禁、版本及实载证据完整', null, preview?.dispatchNo === source.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' + && id(version(preview.recordVersion)) === id(version(loaded.data.recordVersion)) && sha(preview.cargoHash) && sha(preview.loadingCompletionHash) + && (reconcilePersisted ? Boolean(preview.arrangement) : preview.arrange?.allowed === true && !preview.arrangement)) + const receipt = externalTrip?.externalReceipt + if (externalTrip && (!context.createdResources.fleetTrips.includes(externalTrip) || externalTrip.dispatchNo !== source.dispatchNo || receipt?.status !== 'VERIFIED')) throw Error('外接费用只接受本轮同主单真实运营确认') + const plan = coveragePlan(preview, source, receipt) + const totals = new Map() + for (const c of preview.groups.flatMap(g => g.cargo)) totals.set(id(c.dispatchItemId), (totals.get(id(c.dispatchItemId)) || 0) + Number(c.packages)) + io.check('费用分组逐实物客户与箱数核平', null, totals.size === loaded.items.items.length && loaded.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages) + && preview.groups.filter(g => g.cargo.some(c => id(c.dispatchItemId) === id(i.dispatchItemId))).every(g => id(g.customerId) === id(i.customerId) && g.destination.key === i.destination.key))) + const body = reconcilePersisted ? proof.confirmationRequest : { recordVersion: version(preview.recordVersion), idempotencyKey: commandKey(context.marker, `FEE-${source.mode}-${source.dispatchNo}`, 'arrange'), + cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups: plan.groups } + if (!reconcilePersisted) { proof.confirmationRequest = clone(body); proof.originalFees = clone(plan.originalFees); await context.checkpoint?.() } + else io.check('原成功请求仍为同货物和实载', null, body.cargoHash === preview.cargoHash && body.loadingCompletionHash === preview.loadingCompletionHash) + const reject = async (label, changed, pattern) => { + const before = await readDispatch(io, source.dispatchNo) + await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: commandKey(context.marker, `${source.dispatchNo}-${source.mode}`, label) }, label, pattern) + for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, `${label}后无单据`) + io.check(`${label}后整车及实物零漂移`, null, JSON.stringify(await readDispatch(io, source.dispatchNo)) === JSON.stringify(before)) + } + if (!reconcilePersisted) { + for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, '执行前确认无出库单') + await reject('陈旧全车货物摘要拒绝', { cargoHash: `${body.cargoHash[0] === '0' ? '1' : '0'}${body.cargoHash.slice(1)}` }, /货物.*变化/) + const stale = clone(body.groups), selected = stale.find(g => g.feeCoverage.length).feeCoverage[0]; selected.sourceVersion = selected.sourceVersion === 2147483647 ? selected.sourceVersion - 1 : selected.sourceVersion + 1 + await reject('陈旧原费用版本拒绝', { groups: stale }, /原费用版本.*变化/) + const wrong = clone(body.groups) + if (source.mode === 'FULL') wrong[0].fees = clone(source.transferBody.fees) + else wrong.find(g => g.fees.length).fees = [] + await reject(source.mode === 'FULL' ? '全覆盖重复应收拒绝' : '部分覆盖漏填应收拒绝', { groups: wrong }, source.mode === 'FULL' ? /覆盖.*重复|覆盖.*登记/ : /未覆盖.*费用/) + if (externalTrip) { + const extra = clone(body.groups), quoted = preview.groups.find(g => g.externalQuotes.length), input = extra.find(g => g.groupKey === quoted.groupKey), quote = quoted.externalQuotes[0] + input.fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接派送应收反例', currency: quote.currency, amount: quote.amount }) + await reject('外接已有约定运费禁止追加重复应收', { groups: extra }, /重复|覆盖.*登记/) + } + } else { + const stored = await io.get(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, '先核对原成功出库和派送集合') + io.check('原单据完整且未执行', null, stored?.outbound?.dispatchNo === source.dispatchNo && stored.outbound.status === 'WAIT_OUTBOUND' + && stableRows((stored.deliveries || []).map(d => d.deliveryNo)) === stableRows(proof.deliveryNos) && stored.deliveries.every(d => d.status === 'WAIT_DELIVERY')) + } + const result = await io.request('POST', `${path}/transfer-delivery`, body, '按全车真实分组确认原费用并安排派送'), arranged = result?.result + io.check('原生命令返回完整客户出库和客户目的地派送', null, result?.dispatchNo === source.dispatchNo && result.action === 'ARRANGE_DELIVERY' && result.status === 'ACTIVE' && result.stage === 'AFTER_LOADING' + && BigInt(version(result.recordVersion)) > BigInt(version(body.recordVersion)) + && arranged?.dispatchNo === source.dispatchNo && arranged.cargoHash === body.cargoHash && arranged.deliveries?.length === preview.groups.length + && new Set(arranged.deliveries.map(d => d.deliveryNo)).size === arranged.deliveries.length + && arranged.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size + && arranged.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && arranged.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0) === Number(o.packageCount)) && arranged.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' + && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key + && g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) + && arranged.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId)))) + if (reconcilePersisted) io.check('仅恢复原出库和派送集合', null, result.replayed === true && stableRows(arranged.deliveries.map(d => d.deliveryNo)) === stableRows(proof.deliveryNos)) + proof.outbounds = arranged.outbounds; proof.deliveries = arranged.deliveries; proof.command = result + proof.outboundNo = arranged.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo + proof.deliveryNos = arranged.deliveries.map(d => d.deliveryNo).sort() + const snapshots = {}, feeRows = {} + let added = 0n + for (const ref of arranged.deliveries) { + const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = body.groups.find(g => g.groupKey === ref.groupKey) + const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查当前客户目的地费用和原费快照') + io.check('派送只归当前客户和目的地', null, detail?.order?.dispatchNo === source.dispatchNo && detail.order.deliveryNo === ref.deliveryNo + && id(detail.order.customerId) === id(group.customerId) && detail.order.outboundNo === ref.outboundNo + && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId)) + const evidence = detail.feeCoverage, expectedCargo = group.cargo.map(cargoIdentity) + if (input.feeCoverage.length) { + io.check('原费快照保留真实货物、确认人、时间和每票引用', null, evidence?.cargoHash === group.feeCoverageCargoHash && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) + && stableRows(evidence.cargo.map(cargoIdentity)) === stableRows(expectedCargo) && Array.isArray(evidence.references) + && stableRows(evidence.references.map(r => ({ financeFeeId: id(r.source?.financeFeeId), sourceVersion: r.source?.sourceVersion, sourceHash: r.source?.sourceHash, services: r.services }))) === stableRows(input.feeCoverage) + && evidence.references.every(r => id(r.source.customerId) === id(group.customerId) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(r.source.sourceType) + && group.cargo.some(c => id(c.sourceInboundId) === id(r.source.sourceInboundId)) && group.feeSources.some(f => id(f.financeFeeId) === id(r.source.financeFeeId) + && f.currency === r.source.currency && cents(f.amount) === cents(r.source.amount) && f.status === r.source.status))) + } else io.check('未选原费不伪造覆盖证据', null, evidence == null || evidence.references?.length === 0) + io.check('费用为本客户明确正数应收列表', null, Array.isArray(detail.fees) && detail.fees.every(f => f.feeDirection && f.feeType)) + const receivables = detail.fees.filter(f => f.feeDirection === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE' && f.sourceStatus !== 'VOIDED') + const expectedAmounts = [...input.fees.map(f => `${f.currency}:${cents(f.amount)}`), ...group.externalQuotes.filter(q => cents(q.amount) > 0n).map(q => `${q.currency}:${cents(q.amount)}`)].sort() + io.check('仅未覆盖货物和原外接报价产生应收,禁止重复或零值', null, stableRows(receivables.map(f => `${f.currency}:${cents(f.amount)}`).sort()) === stableRows(expectedAmounts) + && receivables.every(f => cents(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED')) + added += input.fees.reduce((n, f) => n + cents(f.amount), 0n) + snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees) + } + const replayed = await replay(io, `${path}/transfer-delivery`, body, result, '费用确认原键原载荷重放') + io.check('费用安排重放明确恢复原成功命令', null, replayed.replayed === true) + for (const ref of arranged.deliveries) { + const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '原键重放后回查每客户费用') + io.check('重放不改确认快照也不新增应收', null, JSON.stringify(detail.feeCoverage) === JSON.stringify(snapshots[ref.deliveryNo]) && stableRows(detail.fees) === feeRows[ref.deliveryNo]) + } + const after = await io.get(`${path}/delivery-arrangement`, '回查原费用来源未被确认改变') + const currentFees = after.groups.flatMap(g => g.feeSources.map(f => ({ groupKey: g.groupKey, ...feeIdentity(f) }))) + io.check('原费用版本金额状态保持不变', null, stableRows(currentFees) === stableRows(proof.originalFees)) + proof.snapshots = snapshots; proof.newReceivable = Number(added) / 100; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof + } catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error } } diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs index 7b20a2b86..7ff847759 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs @@ -6,23 +6,34 @@ import { verifyFleetHandover, cleanupFleetHandoverResources } from './fleet-hand const copy = value => value == null ? value : structuredClone(value) function fixture(fault = '') { const marker = 'TEST-HANDOVER-RUN', now = new Date(Date.now() - 60000).toISOString() - const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', vehicleId: '11', driverId: '21', warehouseId: '92', + const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', dispatchNo: 'PC-ONE', vehicleId: '11', driverId: '21', warehouseId: '92', stops: [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:ONE' }] } const originalTransport = { vehiclePlate: 'ORIGINAL', driverName: '原司机', driverPhone: '111' } const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT', recordVersion: '7', departedAt: now, expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } - const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92', - status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, + const seeds = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92', + packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] })) if (fault === 'r5-layout') { - Object.assign(dispatchDetails[0], { packageCount: 5, pickedPackageCount: 5, items: [ + Object.assign(seeds[0], { packageCount: 5, pickedPackageCount: 5, items: [ { id: 'ITEM-A1', warehousePalletId: 'PALLET-1', packageCount: 1 }, { id: 'ITEM-A2', warehousePalletId: 'PALLET-2', packageCount: 4 }] }) - Object.assign(dispatchDetails[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] }) + Object.assign(seeds[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] }) } - const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: row.packageCount })) - const cargo = dispatchDetails.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, + const cargo = seeds.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: 'PC-ONE', dispatchItemId: item.id, warehousePalletId: item.warehousePalletId, palletNo: 'PAL-REAL', packages: item.packageCount, destinationKey: fault === 'r5-layout' && index === 1 ? 'FBA:TWO' : 'FBA:ONE' }))) + const totalPackages = cargo.reduce((n, row) => n + row.packages, 0) + const counts = { itemCount: cargo.length, palletCount: new Set(cargo.map(r => r.warehousePalletId)).size, + packageCount: totalPackages, pickedPackageCount: totalPackages, shippedPackageCount: totalPackages } + const nativeItems = cargo.map(row => ({ dispatchItemId: row.dispatchItemId, warehousePalletId: row.warehousePalletId, + customerId: row.customerId, destination: { key: row.destinationKey }, scheduledPackages: row.packages, pickedPackages: row.packages })) + const nativeReferences = cargo.map((row,index) => ({ deliveryNo: `DL-${row.customerId}-${index}`, customerId: row.customerId, packageCount: row.packages })) + const liveDispatches = [{ dispatchNo: 'PC-ONE', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '19', counts, + documents: { deliveries: nativeReferences, remark: marker } }] + const dispatchDetails = copy(liveDispatches) + const pdaTask = { dispatchNo: 'PC-ONE', taskNo: 'PC-ONE', warehouseId: '92', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '19', + counts, pickingReconciled: true, items: nativeItems.map(row => ({ dispatchItemId: row.dispatchItemId, customerId: row.customerId, + scheduledPackages: row.scheduledPackages, pickedPackages: row.pickedPackages })) } const pallets = [...new Set(cargo.map(row => row.warehousePalletId))].map(warehousePalletId => ({ warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1 })) const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo, pallets } } } @@ -34,13 +45,13 @@ function fixture(fault = '') { const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' }) const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' }) const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) } - const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId })) - if (fault === 'binding-customer') bindings[0].customerId = 'OTHER' - if (fault === 'dispatch-cargo') liveDispatches[0].items = [{ ...liveDispatches[0].items[0], id: 'OTHER-ITEM' }] + const bindings = [{ dispatchNo: 'PC-ONE' }] + if (fault === 'binding-customer') nativeItems[0].customerId = 'OTHER' + if (fault === 'dispatch-cargo') nativeItems[0].dispatchItemId = 'OTHER-ITEM' const context = { context: { async checkpointFleetHandover() {} }, marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo, - departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: dispatchDetails.reduce((n,row)=>n+row.packageCount,0), dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: row.packageCount })) } } }, + departure: { dispatchNo: 'PC-ONE', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: now, shippedPackages: totalPackages } } } }, assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) }, async requestMultipart(method, path, file) { calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0) @@ -55,14 +66,53 @@ function fixture(fault = '') { } if (path.includes('/handover-status')) { assert.equal(token, 'picker-secret') - const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1) + const dispatchNo = path.split('/').at(-2) const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport, - deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${customer}-${index}`, status: 'WAIT_DELIVERY', packages: row.packages, terminalAt: null, + deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${row.customerId}-${index}`, status: 'DELIVERING', packages: row.packages, terminalAt: null, transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } })), latestHandover: handover ? { handoverNo: handover.handoverNo } : null }) } - if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1))) + if (path.startsWith('/api/pda/v1/last-leg/dispatches/') && !path.endsWith('/handover-status')) { + assert.equal(token, 'picker-secret') + const task = copy(pdaTask) + if (fault === 'pda-task-drift' && handover) task.counts.pickedPackageCount-- + return ok(task) + } + if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { + assert.deepEqual(body.cargo, []); assert.deepEqual(body.batchAllocations, []) + assert.equal(body.vehicle, null); assert.equal(body.driver, null) + if (fault === 'unknown-probe-create') throw new Error('native empty dispatch result unknown') + const index = probes.size + 1 + const next = { tripNo: `PROBE-${index}`, dispatchNo: `EMPTY-${index}`, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0', + counts: { itemCount: 0, packageCount: 0, pickedPackageCount: 0, shippedPackageCount: 0 }, documents: { remark: body.remark }, + warehouseId: body.warehouseId } + probes.set(next.tripNo, next) + const { tripNo: internalOnly, ...response } = next + return ok(response) + } + if (path.startsWith('/api/platform/v1/last-leg/dispatches/EMPTY-')) { + const number = path.match(/dispatches\/(EMPTY-\d+)/)[1], probe = [...probes.values()].find(i => i.dispatchNo === number) + if (path.endsWith('/items')) return ok({ dispatchNo: probe.dispatchNo, recordVersion: probe.recordVersion, items: fault === 'probe-has-cargo' ? [{}] : [] }) + if (path.endsWith('/cancel')) { + assert.equal(body.recordVersion, probe.recordVersion) + for (const [resource, owner] of resources) if (owner === probe.tripNo) resources.delete(resource) + probe.status = 'CANCELLED'; return ok(probe) + } + if (method === 'PUT') { + assert.equal(body.vehicle.mode, 'MASTER'); assert.equal(body.driver.mode, 'MASTER'); assert.ok(body.idempotencyKey) + if (resources.has(`VEHICLE:${body.vehicle.vehicleId}`)) return conflict('车辆已被车次占用') + if (resources.has(`DRIVER:${body.driver.driverId}`)) return conflict('司机已被车次占用') + resources.set(`VEHICLE:${body.vehicle.vehicleId}`, probe.tripNo); resources.set(`DRIVER:${body.driver.driverId}`, probe.tripNo) + Object.assign(probe, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId, vehiclePlate: originalTransport.vehiclePlate, recordVersion: '1' }) + } + const current = copy(probe) + if (fault === 'probe-version-drift') current.recordVersion = '99' + if (fault === 'probe-has-scan') current.counts.pickedPackageCount = 1 + return ok(current) + } + if (path === '/api/platform/v1/last-leg/dispatches/PC-ONE/items') return ok({ dispatchNo: 'PC-ONE', recordVersion: '19', items: nativeItems }) + if (path === '/api/platform/v1/last-leg/dispatches/PC-ONE') return ok(liveDispatches[0]) if (path.endsWith('/master-data/vehicles') && method === 'POST') { assert.equal(body.plateNumber, body.plateNumber.toUpperCase(), 'request must use the formal uppercase vehicle plate') const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle) @@ -86,22 +136,13 @@ function fixture(fault = '') { if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' } return ok(fault === 'review-version' && path.endsWith('/review') ? { ...driver, recordVersion: '1' } : driver) } - if (path.endsWith('/fleet/trips') && method === 'POST') { - const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] }) + if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) { + const rows = [...probes.values()].filter(i => i.vehicleId).map(i => ({ ...i, status: 'PLANNED' })) + return ok({ records: rows, total: rows.length, page: 1, size: 200 }) } if (path.includes('/trips/PROBE-')) { const number = path.match(/\/trips\/([^/]+)/)[1], probe = probes.get(number) - if (path.endsWith('/assignment')) { - if (resources.has(`VEHICLE:${body.vehicleId}`)) return conflict('车辆已被车次占用') - if (resources.has(`DRIVER:${body.driverId}`)) return conflict('司机已被车次占用') - resources.set(`VEHICLE:${body.vehicleId}`, number); resources.set(`DRIVER:${body.driverId}`, number) - Object.assign(probe, { vehicleId: body.vehicleId, driverId: body.driverId, recordVersion: '1' }) - } - if (path.endsWith('/cancel')) { - for (const [resource, owner] of resources) if (owner === number) resources.delete(resource) - probe.status = 'CANCELLED' - } - return ok({ trip: probe, dispatches: [] }) + return ok({ trip: { ...probe, status: 'PLANNED' }, dispatches: [{ dispatchNo: probe.dispatchNo }] }) } if (path.includes('/history?')) { const departure = copy(originalHistory) @@ -159,9 +200,12 @@ test('fresh real departure carries all customer cargo, preserves DEPART and fees assert.equal(result.browser.originalVehiclePlate, 'ORIGINAL'); assert.match(result.browser.replacementVehiclePlate, /^HO-/) assert.equal(f.proof.vehicleId, '12'); assert.equal(f.proof.driverId, '22'); assert.equal(f.proof.licenseFileId, 'TEST-LICENSE-FILE') assert.equal(result.replacement.certificate.synthetic, true); assert.ok(result.replacement.certificate.contentBase64) + assert.equal(result.dispatchNos.length, 1); assert.equal(result.pdaTasksBefore.length, 1) + assert.equal(f.proof.marker, result.replacement.vehiclePlate, 'current unique plate follows the resources for later native closure probes') assert.doesNotMatch(JSON.stringify(result), /picker-secret/) assert.ok(f.calls.filter(row => row.assertion).every(row => row.passed)) assert.ok(!f.calls.some(row => row.path?.endsWith('/depart') || row.path?.endsWith('/complete') || row.path?.endsWith('/cancel'))) + assert.ok(!f.calls.some(row => row.method === 'POST' && /\/fleet\/trips$|\/assignment$/.test(row.path || ''))) assert.equal(f.resources.get('VEHICLE:11'), 'PROBE-1'); assert.equal(f.resources.get('DRIVER:21'), 'PROBE-1') }) @@ -238,7 +282,8 @@ for (const [fault, message] of Object.entries({ 'driver-default-nonempty': '接 'review-version': '接替司机已正式审核', 'preview-identity': '交接预览绑定原车次', 'preview-capacity': '交接预览容量', 'release-version': '原车辆已结束', 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', 'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用', - 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) { + 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆', + 'pda-task-drift': '交接不改变PDA原生整车版本及扫码归属', 'unknown-probe-create': 'native empty dispatch result unknown' })) { test(`business counterexample ${fault} cannot be reported as successful handover`, async () => { const f = fixture(fault) await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), new RegExp(message)) @@ -273,12 +318,22 @@ test('explicit closure cleanup cancels only test probes and disables newly creat assert.ok(!f.calls.some(row => row.method === 'DELETE')) }) +for (const fault of ['probe-has-cargo', 'probe-version-drift', 'probe-has-scan']) test(`native empty-dispatch cleanup refuses unexpected state: ${fault}`, async () => { + const f = fixture(fault) + await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails) + f.trip.status = 'COMPLETED'; f.trip.vehicleRelease = { actualEndAt: new Date().toISOString() }; f.trip.driverRelease = { actualEndAt: new Date().toISOString() } + f.resources.delete('VEHICLE:12'); f.resources.delete('DRIVER:22') + const before = f.calls.length + await assert.rejects(cleanupFleetHandoverResources(f.context, 'test', f.proof), /仅取消本轮未执行/) + assert.ok(f.calls.slice(before).filter(row => row.method).every(row => row.method === 'GET')) +}) + test('formal driver response accepts empty profile with omitted or NULL optional fields', async () => { const f = fixture('driver-default-null') assert.equal((await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)).status, 'VERIFIED') }) -test('r5 two dispatches, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => { +test('r5 one cross-customer dispatch, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => { const f = fixture('r5-layout') const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails) assert.equal(result.status, 'VERIFIED'); assert.equal(result.pdaAfter.flatMap(row=>row.deliveries).length, 3) diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs index 086fcf118..616459ace 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs @@ -25,7 +25,8 @@ function requireRun(context, tripProof) { const run = context.createdResources?.fleetAcceptance if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo - || run.departure?.tripNo !== tripProof.tripNo || run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt) + || run.departure?.dispatchNo !== tripProof.dispatchNo || run.departure.status !== 'ACTIVE' + || run.departure.stage !== 'DELIVERING' || !run.departure.result?.departedAt || !count(run.departure.result.shippedPackages)) throw new Error('在途交接仅接受当前marker登记的原车次及真实DEPART结果,禁止借用旧样本') return run } @@ -60,10 +61,17 @@ function io(context, module, proof) { } return { check, request } } -function dispatchFacts(row) { - const fields = ['dispatchNo', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', - 'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items'] - return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]])) +function cargoFacts(values) { + const totals = new Map() + for (const row of values) { + const facts = { itemId: text(row.dispatchItemId), customerId: text(row.customerId), palletId: text(row.warehousePalletId), + destinationKey: row.destination?.key ?? row.destinationKey } + if (!facts.itemId || !facts.customerId || !facts.palletId || !facts.destinationKey) throw new Error('原生货物缺少真实明细客户或目的地') + const identity = stable(facts), packages = Number(row.scheduledPackages ?? row.packages) + if (!count(packages)) throw new Error('原生货物缺少有效真实箱数') + totals.set(identity, { ...facts, packages: (totals.get(identity)?.packages || 0) + packages }) + } + return [...totals.values()].sort((a, b) => stable(a).localeCompare(stable(b))) } function feeFacts(rows) { if (!Array.isArray(rows)) throw new Error('费用来源不是正式列表') @@ -85,10 +93,9 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length) throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑') const numbers = dispatchDetails.map(row => row.dispatchNo) - if (new Set(numbers).size !== numbers.length || numbers.some(number => !number) - || !same([...numbers].sort(), run.departure.dispatches?.map(row => row.dispatchNo).sort()) - || !dispatchDetails.every(row => count(row.packageCount)) - || Number(run.departure.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.packageCount), 0)) + if (numbers.length !== 1 || numbers[0] !== tripProof.dispatchNo || numbers[0] !== run.departure.dispatchNo + || !dispatchDetails.every(row => count(row.counts?.packageCount)) + || Number(run.departure.result.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.counts.packageCount), 0)) throw new Error('交接输入排车清单及箱数未覆盖本轮真实发车') const proof = tripProof.handover = { marker: context.marker, fixtureMarker: tripProof.marker, status: 'CHECKING', requests: [], probes: [], dispatchNos: [...numbers], stockEvidence: 'CALLER_FULL_CHAIN_INVENTORY_AUDIT_REQUIRED' } @@ -96,7 +103,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke try { const detail = await request('GET', base, undefined, '读取真实在途车次及分配资源') check('真实车次属于本轮且已经发车', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'IN_TRANSIT' - && detail.trip.departedAt === run.departure.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId) + && detail.trip.departedAt === run.departure.result.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId) && text(detail.trip.driverId) === text(tripProof.driverId) && text(detail.trip.warehouseId) === text(tripProof.warehouseId) && validVersion(detail.trip.recordVersion) && same(detail.dispatches?.map(row => row.dispatchNo).sort(), [...numbers].sort())) const initialHistory = await request('GET', `${base}/history?page=1&size=200`, undefined, '读取不可变原DEPART证据') @@ -109,26 +116,36 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke && text(original.trip.vehicleId) === text(detail.trip.vehicleId) && text(original.trip.driverId) === text(detail.trip.driverId) && original.confirmedLoad?.cargo?.length > 0 && original.confirmedLoad?.pallets?.length > 0) proof.previous = { vehicleId: text(detail.trip.vehicleId), driverId: text(detail.trip.driverId), ...transport(detail.trip) } - proof.dispatchBefore = []; proof.pdaBefore = []; proof.feesBefore = [] + proof.dispatchBefore = []; proof.pdaBefore = []; proof.pdaTasksBefore = []; proof.feesBefore = [] for (const expected of dispatchDetails) { const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库') - const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo) + const items = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '读取原生主单逐客户真实货物') const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo) - const items = values => values.map(item => ({ itemId: text(item.id ?? item.dispatchItemId), palletId: text(item.warehousePalletId), - packages: Number(item.packageCount ?? item.packages) })).sort((a, b) => a.itemId.localeCompare(b.itemId)) - check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED' - && Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount) - && binding?.customerId && cargo.length > 0 && cargo.every(item => text(item.customerId) === text(binding.customerId)) - && same(items(row.items || []), items(cargo)) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) - proof.dispatchBefore.push(dispatchFacts(row)) + check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'ACTIVE' && row.stage === 'DELIVERING' + && Number(row.counts?.shippedPackageCount) === Number(expected.counts.packageCount) && Number(row.counts?.pickedPackageCount) === Number(expected.counts.packageCount) + && items.dispatchNo === row.dispatchNo && text(items.recordVersion) === text(row.recordVersion) && cargo.length > 0 + && same(cargoFacts(items.items || []), cargoFacts(cargo)) && row.documents?.deliveries?.length > 0) + proof.dispatchBefore.push({ dispatchNo: row.dispatchNo, detail: row, items }) + const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}`, undefined, 'PDA读取原生整车任务和客户来源', { token: pickerToken }) + check('PDA原生作业与整车真实客户货物核平', task.dispatchNo === row.dispatchNo && task.status === 'ACTIVE' && task.stage === 'DELIVERING' + && text(task.warehouseId) === text(tripProof.warehouseId) && text(task.recordVersion) === text(row.recordVersion) + && same(task.counts, row.counts) && task.pickingReconciled === true + && same(task.items?.map(i => ({ dispatchItemId: text(i.dispatchItemId), customerId: text(i.customerId), scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages })) + .sort((a,b) => a.dispatchItemId.localeCompare(b.dispatchItemId)), + items.items.map(i => ({ dispatchItemId: text(i.dispatchItemId), customerId: text(i.customerId), scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages })) + .sort((a,b) => a.dispatchItemId.localeCompare(b.dispatchItemId)))) + proof.pdaTasksBefore.push(task) const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken }) check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo && pda.latestHandover == null && same(transport(pda.originalDeparture), transport(original.trip)) && pda.deliveries?.length > 0 - && pda.deliveries.every(item => !item.terminalAt && item.status === 'WAIT_DELIVERY' && same(transport(item.transport), transport(detail.trip))) - && pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.packageCount)) + && pda.deliveries.every(item => !item.terminalAt && item.status === 'DELIVERING' && same(transport(item.transport), transport(detail.trip))) + && pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.counts.packageCount) + && same(pda.deliveries.map(i => i.deliveryNo).sort(), row.documents.deliveries.map(i => i.deliveryNo).sort())) proof.pdaBefore.push(pda) for (const delivery of pda.deliveries) { - const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(binding.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}` + const reference = row.documents.deliveries.find(i => i.deliveryNo === delivery.deliveryNo) + check('派送费用严格沿真实主单逐客户单据归属', reference?.customerId && cargo.some(i => text(i.customerId) === text(reference.customerId))) + const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(reference.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}` proof.feesBefore.push({ path, facts: feeFacts(await request('GET', path, undefined, '冻结本次派送费用来源')) }) } } @@ -214,8 +231,12 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke await request('POST', `${base}/handovers`, proof.staleRequest, '陈旧版本交接必须拒绝', { conflict: /车次.*(变化|修改)|版本/ }) check('陈旧命令没有改变车次版本或资源', same(await request('GET', base, undefined, '拒绝后核对车次零变更'), after)) const firstProbe = await createProbe(1) - const assignment = { recordVersion: firstProbe.recordVersion, vehicleId: proof.previous.vehicleId, driverId: proof.previous.driverId, reason: '本轮旧资源占用核对' } - await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ }) + const assignment = { recordVersion: firstProbe.recordVersion, idempotencyKey: key(), + vehicle: { mode: 'MASTER', vehicleId: proof.previous.vehicleId }, + driver: { mode: 'MASTER', driverId: proof.previous.driverId, evidence: { expectedTaskEndAt: detail.trip.expectedDriverEndAt } }, + plannedDepartureAt: new Date().toISOString(), estimatedArrivalAt: null, remark: `${context.marker}本轮旧资源占用核对` } + firstProbe.assignmentRequest = clone(assignment) + await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ }) const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release` proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() } const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务') @@ -225,7 +246,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke && vehicleReleased.vehicleRelease?.idempotencyKey === proof.vehicleReleaseRequest.idempotencyKey && vehicleReleased.vehicleRelease?.location === proof.vehicleReleaseRequest.location && vehicleReleased.vehicleRelease?.vehicleStatus === 'IN_SERVICE' && vehicleReleased.driverRelease == null) - await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ }) + await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ }) proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() } const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务') check('原车辆司机分别保留结束事实', released.handoverNo === handover.handoverNo && released.tripNo === tripProof.tripNo @@ -234,20 +255,53 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke && Date.parse(released.driverRelease?.actualEndAt) === Date.parse(proof.driverReleaseRequest.actualEndAt) && released.driverRelease?.idempotencyKey === proof.driverReleaseRequest.idempotencyKey && released.driverRelease?.location === proof.driverReleaseRequest.location) - const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划') - firstProbe.recordVersion = assigned.trip.recordVersion - check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId) + const assigned = await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '将已释放原资源分配给本轮无货计划') + check('原资源真正可重新分配', assigned.dispatchNo === firstProbe.dispatchNo && assigned.status === 'ACTIVE' && assigned.stage === 'BEFORE_LOADING' + && BigInt(text(assigned.recordVersion)) > BigInt(text(firstProbe.recordVersion))) + firstProbe.recordVersion = assigned.recordVersion + const candidateRows = []; let candidateTotal + for (let page = 1; ; page++) { + const candidates = await request('GET', `${FLEET}/trips?${new URLSearchParams({ keyword: proof.previous.vehiclePlate, + warehouseId: text(tripProof.warehouseId), page: text(page), size: '200' })}`, undefined, '定位本轮空主单自动Fleet资源记录') + check('资源候选分页完整且稳定', Array.isArray(candidates.records) && Number(candidates.page) === page && Number(candidates.size) === 200 + && Number.isSafeInteger(Number(candidates.total)) && Number(candidates.total) >= 0 + && (candidateTotal === undefined || candidateTotal === Number(candidates.total))) + candidateTotal ??= Number(candidates.total) + check('资源候选未缺页', candidates.records.length > 0 || candidateRows.length === candidateTotal) + candidateRows.push(...candidates.records); if (candidateRows.length >= candidateTotal) break + } + check('资源候选总数和业务号无重复', candidateRows.length === candidateTotal && new Set(candidateRows.map(i => i.tripNo)).size === candidateRows.length) + const matches = [] + for (const row of candidateRows.filter(row => text(row.vehicleId) === proof.previous.vehicleId && text(row.driverId) === proof.previous.driverId + && row.vehiclePlate === proof.previous.vehiclePlate && text(row.warehouseId) === text(tripProof.warehouseId))) { + const candidate = await request('GET', pathFor(row.tripNo), undefined, '核对新资源记录与原生空单唯一绑定') + if (candidate.dispatches?.some(i => i.dispatchNo === firstProbe.dispatchNo)) { + check('新资源记录只能绑定本轮唯一空主单', candidate.trip?.tripNo === row.tripNo && candidate.dispatches.length === 1 + && text(candidate.trip.vehicleId) === proof.previous.vehicleId && text(candidate.trip.driverId) === proof.previous.driverId) + matches.push(candidate) + } + } + check('原资源真正可重新分配且唯一定位', matches.length === 1 && matches[0].trip.tripNo !== tripProof.tripNo) + firstProbe.tripNo = matches[0].trip.tripNo const persistedReleases = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化资源结束记录') const releaseSnapshot = persistedReleases.records?.find(row => row.handoverNo === proof.handoverNo) check('原车辆结束重放不改记录', same(await request('POST', releaseBase, proof.vehicleReleaseRequest, '新计划占用后重放旧车辆结束请求'), releaseSnapshot)) check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot)) const secondProbe = await createProbe(2) - await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ }) + const secondAssignment = { ...assignment, idempotencyKey: key(), recordVersion: secondProbe.recordVersion } + await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, secondAssignment, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ }) const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10).toUpperCase()}` } const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆') check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id) - await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ }) - for (const expected of proof.dispatchBefore) check('交接不改变原排车出库和货物归属', same(dispatchFacts(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对排车原始出库事实')), expected)) + await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, { ...secondAssignment, idempotencyKey: key(), vehicle: { mode: 'MASTER', vehicleId: spare.id } }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ }) + for (const expected of proof.dispatchBefore) { + check('交接不改变原排车出库和货物归属', same(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对原生排车出库事实'), expected.detail) + && same(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '核对原生排车逐客户货物事实'), expected.items)) + } + for (const previous of proof.pdaTasksBefore) { + const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}`, undefined, 'PDA回查原生整车版本及扫码归属未变化', { token: pickerToken }) + check('交接不改变PDA原生整车版本及扫码归属', same(current, previous)) + } proof.pdaAfter = [] for (const previous of proof.pdaBefore) { const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}/handover-status`, undefined, 'PDA只读核对接替资源与原始发车', { token: pickerToken }) @@ -270,16 +324,19 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke proof.status = 'VERIFIED' // Caller finishFleetTrip must finish the currently assigned resources, not the original pair. tripProof.vehicleId = proof.replacement.vehicleId; tripProof.driverId = proof.replacement.driverId; tripProof.licenseFileId = proof.replacement.licenseFileId + tripProof.marker = proof.replacement.vehiclePlate return proof } catch (error) { proof.status = 'FAILED'; proof.failure = { message: error.message, requestCount: proof.requests.length }; throw error } async function createProbe(index) { const saved = { marker: context.marker, index }; proof.probes.push(saved) - const value = await request('POST', `${FLEET}/trips`, { warehouseId: tripProof.warehouseId, idempotencyKey: key(), - plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), - stops: tripProof.stops, remark: `${context.marker}在途交接资源验证无货计划${index}` }, '创建本轮无货资源核对计划') - check('核对计划不承载货物且尚未发车', value?.trip?.tripNo && value.trip.status === 'PLANNED' && value.dispatches?.length === 0 && validVersion(value.trip.recordVersion)) - saved.tripNo = value.trip.tripNo; saved.recordVersion = value.trip.recordVersion; return saved + saved.createRequest = { warehouseId: text(tripProof.warehouseId), idempotencyKey: key(), vehicle: null, driver: null, + plannedDepartureAt: new Date().toISOString(), estimatedArrivalAt: null, cargo: [], batchAllocations: [], + remark: `${context.marker}在途交接资源验证原生空排车${index}` } + const value = await request('POST', DISPATCHES, saved.createRequest, '创建本轮无货资源核对计划') + check('核对计划不承载货物且尚未发车', value?.dispatchNo && value.status === 'ACTIVE' && value.stage === 'BEFORE_LOADING' + && value.counts?.packageCount === 0 && value.counts?.pickedPackageCount === 0 && value.counts?.shippedPackageCount === 0 && validVersion(value.recordVersion)) + saved.dispatchNo = value.dispatchNo; saved.recordVersion = value.recordVersion; return saved } } @@ -295,10 +352,15 @@ export async function cleanupFleetHandoverResources(context, module, tripProof) check('整车结案且接替车辆司机均已释放', current.trip?.status === 'COMPLETED' && current.trip.vehicleRelease && current.trip.driverRelease && text(current.trip.vehicleId) === proof.replacement.vehicleId && text(current.trip.driverId) === proof.replacement.driverId) for (const probe of [...proof.probes].reverse()) { - const path = pathFor(probe.tripNo), detail = await request('GET', path, undefined, '核对仅本轮无货资源计划') - check('仅取消本轮未执行无货计划', detail.trip?.tripNo === probe.tripNo && detail.trip.status === 'PLANNED' && detail.trip.remark?.includes(context.marker) && detail.dispatches?.length === 0) - const cancelled = await request('POST', `${path}/cancel`, { recordVersion: detail.trip.recordVersion, reason: '本轮交接验证完成,取消无货核对计划' }, '取消本轮资源核对计划') - check('无货计划已取消', cancelled.trip?.status === 'CANCELLED') + const path = `${DISPATCHES}/${encodeURIComponent(probe.dispatchNo)}`, detail = await request('GET', path, undefined, '核对仅本轮原生空排车') + const items = await request('GET', `${path}/items`, undefined, '核对本轮空排车零实际货物') + check('仅取消本轮未执行无货计划', detail.dispatchNo === probe.dispatchNo && detail.status === 'ACTIVE' && detail.stage === 'BEFORE_LOADING' + && detail.documents?.remark?.includes(context.marker) && text(detail.recordVersion) === text(probe.recordVersion) + && detail.counts?.itemCount === 0 && detail.counts?.packageCount === 0 && detail.counts?.pickedPackageCount === 0 && detail.counts?.shippedPackageCount === 0 + && items.dispatchNo === probe.dispatchNo && text(items.recordVersion) === text(detail.recordVersion) && items.items?.length === 0) + probe.cancelRequest = { recordVersion: detail.recordVersion, idempotencyKey: key(), reason: '本轮交接验证完成,取消原生空排车并保留历史' } + const cancelled = await request('POST', `${path}/cancel`, probe.cancelRequest, '取消本轮资源核对计划') + check('无货计划已取消', cancelled.dispatchNo === probe.dispatchNo && cancelled.status === 'CANCELLED') } for (const vehicleId of [proof.replacement.vehicleId, proof.spareVehicleId]) { const vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(vehicleId)}`, undefined, '读取本轮接替或隔离车辆版本') diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index 884f156b7..4ee2b7d9e 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -6,180 +6,223 @@ import { tmpdir } from 'node:os' import { createHash } from 'node:crypto' import { selectReturnSources, verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit } from './fleet-return-redispatch-acceptance.mjs' +const BASE = '/api/platform/v1/last-leg', DISPATCH = `${BASE}/dispatches`, PDA = '/api/pda/v1/last-leg/dispatches', CUSTOMER = '900719925474099301', WAREHOUSE = '92' +const copy = value => structuredClone(value) function fixture(fault = '') { - const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = [] - let returned = false, originalSigned = false, newSigned = false, departed = false + const trip = { tripNo: 'TR-ORIGINAL', dispatchNo: 'DP-ORIGINAL' }, requests = [], checkpoints = [], ledger = new Map(), controls = {} const masterIds = { A: '9007199254740993', B: '9007199254740995' } - const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: masterIds[name], lastMileOrderNo: `LM-${name}`, - customerId: 'C', overseasWarehouseId: 'W', inboundOrderId: fault === 'no-inbound' ? null : String(index + 10), inboundCode: `IN-${name}`, - deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', returnSources: [{ outboundPalletItemId: String(index + 20), inventoryPositionId: String(index + 30), maxReturnPackages: index ? 3 : 4 }] })) - const tail = name => ({ id: masterIds[name], orderNo: `LM-${name}`, deliveryOrderNo: 'DL-ORIGINAL', outboundOrderNo: 'OUT-ORIGINAL', orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, - subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' - : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' }) - const order = name => name === 'DL-NEW' ? { deliveryNo: name, dispatchNo: 'DP-NEW', packageCount: 2, signedPackages: newSigned ? 2 : null, signedAt: newSigned ? 'new-signature' : null } - : { deliveryNo: 'DL-ORIGINAL', dispatchNo: 'DP-ORIGINAL', outboundNo: 'OUT-ORIGINAL', status: 'DELIVERING', packageCount: 7, - signedPackages: originalSigned ? 5 : null, signedAt: originalSigned ? newSigned && fault === 'rewritten-original' ? 'changed' : 'original-signature' : null } - const envelope = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request-id', traceId: 'trace-id' }) - const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: 'C', warehouseId: 'W', tripNo: trip.tripNo, departure: { status: 'IN_TRANSIT', dispatches: [{ dispatchNo: 'DP-ORIGINAL' }] } } }, - assert() {}, async checkpoint() { checkpoints.push(structuredClone(trip.returnRedispatch)) }, - async requestMultipart(method, path, file) { requests.push({ method, path, file: file.name }); return envelope({ fileId: '99' }) }, - async request(method, path, body, token) { - requests.push({ method, path, body: structuredClone(body), token }) - if (path.startsWith('/api/platform/inbounds?')) { - const code = new URLSearchParams(path.split('?')[1]).get('inboundCode'), name = code.slice(-1) - const delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned - return envelope({ records: [{ inboundCode: code, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) - } - if (path.includes('/orders/')) { - const masterId = path.split('/orders/')[1].split('?')[0] - const name = Object.keys(masterIds).find(name => masterIds[name] === masterId) - assert.ok(name, `unknown master ${masterId}`) - return envelope({ order: tail(name) }) - } - if (path.includes('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations) - if (path.includes('/mark-return')) { - assert.equal(body.lastMileOrderId, masterIds.A) - assert.equal(Object.hasOwn(body, 'inboundOrderId'), false) - assert.deepEqual(body.items, [{ outboundPalletItemId: '20', inventoryPositionId: '30', packageCount: 2 }]) - return envelope({ exceptionNo: 'EX-NEW', version: 0 }) - } - if (path.includes('/start-processing')) return envelope({ status: 'PROCESSING', version: 1 }) - if (path.includes('/exception-orders/') && path.includes('/complete')) { - const replayed = returned; returned = true - if (fault === 'unknown-return') throw Error('connection lost after commit') - return envelope({ status: 'COMPLETED', restoredPackages: fault === 'wrong-return' ? 3 : 2, inventoryAdjustmentId: '88', replayed }) - } - if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') } - if (path.endsWith('/pallets/100')) return envelope({ warehouseId: 'W', locationId: '200', palletCode: context.palletCode }) - if (path.includes('/locations?')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] }) - if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: masterIds.A, packageCount: fault === 'wrong-candidate' ? 4 : 2 }]) - if (path.endsWith('/drafts/containers')) return envelope({ id: '400' }) - if (path.endsWith('/draft-locks/lock')) { assert.equal(body.sourceType, 'RETURN_RECEIPT'); assert.equal(body.planPalletId, undefined); return envelope(null) } - if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, []); assert.deepEqual(body.returnReceiptIds, ['300']); return envelope({ dispatchNo: 'DP-NEW', packageCount: 2 }) } - if (path.endsWith('/fleet/trips/TR-NEW')) return envelope({ trip: { vehiclePlate: 'NEW', driverName: 'driver', driverPhone: 'phone' } }) - if (path.endsWith('/transfer-delivery')) return envelope({ deliveries: [{ deliveryNo: 'DL-NEW' }] }) - if (path.endsWith('/depart')) { assert.equal(token, 'private-pda-token'); const replayed = departed; departed = true; return envelope({ tripNo: 'TR-NEW', shippedPackages: 2, replayed }) } - if (path.includes('/delivery-orders/')) return envelope({ order: order(path.split('/delivery-orders/')[1].split('?')[0]) }) - throw Error(`Unexpected ${method} ${path}`) - } } - const dependencies = { - async onBeforeRedispatchSignature(proof) { - assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING') - if (fault === 'audit-gate') throw Error('Audit gate not approved') - return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } + const relations = ['A', 'B'].map((name, n) => ({ lastMileOrderId: masterIds[name], lastMileOrderNo: `LM-${name}`, customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, + inboundOrderId: fault === 'no-inbound' ? null : `${n + 10}`, inboundCode: `IN-${name}`, deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', + returnSources: [{ outboundPalletItemId: `${n + 20}`, inventoryPositionId: `${n + 30}`, maxReturnPackages: n ? 3 : 4, returnCartonId: null }] })) + let returned = false, originalSigned = false, newSigned = false, departed = false, picked = false, recordVersion = 0, stage = 'BEFORE_LOADING', review = false, arranged = false, created = false, nextCreated = false, nextCancelled = false + let vehiclePlate, vehicleOwner = 'TR-NEW', driverOwner = 'TR-NEW', closed = false, vehicleReleased = false, driverReleased = false, tripVersion = 0 + const rootDone = name => name === 'A' ? newSigned || fault === 'root-premature-A' && originalSigned : originalSigned + const tail = name => ({ id: masterIds[name], orderNo: `LM-${name}`, deliveryOrderNo: 'DL-ORIGINAL', outboundOrderNo: 'OUT-ORIGINAL', orderType: 'FIRST_LEG', + sourceInboundId: name === 'A' ? '801' : '802', sourceOrderId: name === 'A' ? '701' : '702', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, + subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' }) + const signedQuantity = (kind, fallback) => controls[kind]?.override ? controls[kind].value : fallback + const order = name => name === 'DL-NEW' ? { deliveryNo: name, customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, dispatchNo: 'DP-NEW', outboundNo: 'OUT-NEW', status: newSigned ? 'DELIVERED' : departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2, + signedPackages: newSigned ? signedQuantity('newQuantity', 2) : null, signedAt: newSigned ? '2026-10-19T04:00:00Z' : null, version: 0 } + : { deliveryNo: 'DL-ORIGINAL', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, dispatchNo: 'DP-ORIGINAL', outboundNo: 'OUT-ORIGINAL', status: originalSigned ? 'DELIVERED' : controls.waitDelivery ? 'WAIT_DELIVERY' : 'DELIVERING', packageCount: 7, + signedPackages: originalSigned ? signedQuantity('originalQuantity', 5) : null, signedAt: originalSigned ? newSigned && fault === 'rewritten-original' ? 'changed' : '2026-10-19T02:00:00Z' : null, version: controls.deliveryVersion ?? 0 } + const counts = empty => ({ itemCount: empty ? 0 : 1, palletCount: empty ? 0 : 1, packageCount: empty ? 0 : 2, pickedPackageCount: empty ? 0 : picked ? 2 : 0, shippedPackageCount: empty ? 0 : departed ? 2 : 0 }) + const destination = { key: 'FBA:ONT8', type: 'FBA', warehouseCode: 'ONT8' } + const stops = [{ index: 0, name: 'Actual Origin', destinationKey: 'ORIGIN:92' }, { index: 1, name: 'ONT8', destinationKey: 'FBA:ONT8' }] + const nativeItem = () => ({ dispatchItemId: '501', customerId: CUSTOMER, sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), sourceType: 'RETURN_RECEIPT', planPalletId: null, returnReceiptId: '300', warehousePalletId: '100', palletNo: context.palletCode, + inboundCode: 'IN-A', destination, scheduledPackages: 2, pickedPackages: picked ? 2 : 0, positions: [{ positionId: '201', availablePackages: picked ? 0 : 2 }] }) + const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage, + reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked, + vehicleInfo: { vehicle: { vehicleId: '601' }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] }) + const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 }) + const outbound = () => ({ customerId: CUSTOMER, outboundNo: 'OUT-NEW', status: departed ? 'OUTBOUNDED' : 'WAIT_OUTBOUND', packageCount: 2 }) + const command = (action, result = {}, next = false) => ({ ...detail(next), action, result, replayed: false }) + const tripDetail = next => ({ trip: { tripNo: next ? 'TR-NEXT' : 'TR-NEW', warehouseId: WAREHOUSE, vehicleId: '601', driverId: '701', vehiclePlate, tripPurpose: 'DELIVERY', recordVersion: tripVersion, status: closed && !next ? 'COMPLETED' : 'PLANNED', + completedAt: closed && !next ? '2026-10-19T05:00:00Z' : null, vehicleRelease: vehicleReleased && !next ? { endLocation: 'ONT8' } : null, driverRelease: driverReleased && !next ? { endLocation: 'ONT8' } : null, stops }, dispatches: [{ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW' }] }) + const stockLedger = () => returned ? [{ id: '901', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: 'IN-A', palletNo: context.palletCode, businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-NEW', outboundOrderNo: 'OUT-ORIGINAL', deltaPackages: 2 }] : [] + const envelope = (data, code = '200', message = '') => ({ http: 200, body: { code, message, data: copy(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: CUSTOMER, warehouseId: WAREHOUSE, tripNo: trip.tripNo, + departure: { dispatchNo: trip.dispatchNo, action: 'DEPART', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: '2026-10-19T01:00:00Z', deliveries: [{ customerId: CUSTOMER, deliveryNo: 'DL-ORIGINAL' }] } } } }, + assert(_m, label, _r, passed) { if (!passed) throw Error(label) }, addStep(_m, label, _r, passed) { if (!passed) throw Error(label) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, + async checkpoint() { checkpoints.push(copy(trip.returnRedispatch)) }, + async requestMultipart(method, path, file) { + requests.push({ method, path, file: file.name }) + if (path.includes('/fleet/drivers/701/license')) return envelope({ licenseFileId: '801', recordVersion: 1 }) + return envelope({ fileId: '99' }) }, - async prepareFleetTrip() { return { tripNo: 'TR-NEW' } }, async confirmFleetLoading() { return 1 }, async pickAll() { return true }, - async finishFleetTrip() { return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, - async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } }, - } - const input = { customerId: 'C', warehouseId: 'W', pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] } - return { context, trip, input, dependencies, requests, checkpoints, relations, masterIds } + async request(method, raw, body, token) { + requests.push({ method, path: raw, body: copy(body), token }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, DISPATCH), ledgerKey = `${method}:${normalized}:${body?.idempotencyKey}` + if (body?.idempotencyKey && ledger.has(ledgerKey) && !path.endsWith('/resources/release')) { const prior = ledger.get(ledgerKey); assert.deepEqual(body, prior.body); return envelope({ ...copy(prior.data), replayed: true }) } + const remember = data => { if (body?.idempotencyKey) ledger.set(ledgerKey, { body: copy(body), data: copy(data) }); return envelope(data) } + if (path === '/api/platform/inbounds') { const name = params.get('inboundCode').slice(-1), delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned && fault !== 'domestic-blocked-B'; return envelope({ records: [{ inboundCode: `IN-${name}`, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) } + if (path.startsWith('/api/platform/v1/orders/first-leg/')) { + const orderId = path.split('/').at(-1), name = orderId === '701' ? 'A' : 'B', done = rootDone(name) + if (['shared-root', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending'].includes(fault)) { + const isRoot = orderId === '700', complete = isRoot ? newSigned || fault === 'root-premature-with-pending' : done || name === 'A' && fault === 'child-premature-A' + return envelope({ order: { id: orderId, orderNo: isRoot ? 'FIRST-ROOT' : `FIRST-${name}`, customerId: CUSTOMER, tenantId: CUSTOMER, orderRole: isRoot ? 'MASTER' : 'CHILD', + rootOrderId: fault === 'borrowed-child' && name === 'B' && !isRoot ? '999' : '700', parentOrderId: '700', status: complete ? 'COMPLETED' : 'PROCESSING' }, + statusHistory: isRoot && complete ? [{ action: 'OVERSEAS_DELIVERED' }, { action: 'FULFILLMENT_COMPLETED' }] : [] }) + } + return envelope({ order: { id: orderId, orderNo: `FIRST-${name}`, customerId: CUSTOMER, tenantId: CUSTOMER, orderRole: 'MASTER', status: done ? 'COMPLETED' : 'PROCESSING' }, statusHistory: done ? [{ action: 'OVERSEAS_DELIVERED' }, { action: 'FULFILLMENT_COMPLETED' }] : [] }) + } + if (path.endsWith('/fulfillment-tree')) { const orderId = path.split('/').at(-2); return envelope({ parentOrderId: orderId, parentOrderNo: orderId === '700' ? 'FIRST-ROOT' : orderId === '701' ? 'FIRST-A' : 'FIRST-B', + children: orderId === '700' ? [{ orderId: '701', orderNo: 'FIRST-A' }, { orderId: '702', orderNo: 'FIRST-B' }] : [{ orderId, orderNo: orderId === '701' ? 'FIRST-A' : 'FIRST-B' }] }) } + if (path.startsWith(`${BASE}/orders/`)) { const name = Object.keys(masterIds).find(name => masterIds[name] === path.split('/').at(-1)); assert.ok(name); return envelope({ order: tail(name) }) } + if (path.endsWith('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations) + if (path.endsWith('/mark-return')) { assert.equal(body.lastMileOrderId, masterIds.A); assert.equal(Object.hasOwn(body, 'inboundOrderId'), false); assert.deepEqual(body.items, [{ outboundPalletItemId: '20', inventoryPositionId: '30', packageCount: 2 }]); return remember({ exceptionNo: 'EX-NEW', version: 0, replayed: false }) } + if (path.endsWith('/start-processing')) return remember({ status: 'PROCESSING', version: 1 }) + if (path.includes('/exception-orders/') && path.endsWith('/complete')) { returned = true; if (fault === 'unknown-return') throw Error('connection lost after commit'); return remember({ status: 'COMPLETED', restoredPackages: fault === 'wrong-return' ? 3 : 2, inventoryAdjustmentId: '88', version: 2, replayed: false }) } + if (path.endsWith('/exception-orders/EX-NEW')) return envelope({ order: { exceptionNo: 'EX-NEW', customerId: CUSTOMER, outboundNo: 'OUT-ORIGINAL' }, returnReceipts: [{ receiptNo: 'RSR-300', receivedPackages: 2 }] }) + if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') } + if (path.endsWith('/pallets/100')) return envelope({ warehouseId: WAREHOUSE, locationId: '200', palletCode: context.palletCode }) + if (path.endsWith('/locations')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] }) + if (path.endsWith('/inventory/balances')) return envelope({ records: returned && !departed ? [{ id: '401', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE }] : [], total: returned && !departed ? 1 : 0 }) + if (path.includes('/inventory/balances/') && path.endsWith('/positions')) return envelope([{ id: '201', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: 'IN-A', palletId: '100', palletNo: context.palletCode, availablePackages: picked ? 0 : 2, pickedPackages: picked ? 2 : 0 }]) + if (path.endsWith('/inventory/ledger')) { const rows = stockLedger(); if (fault === 'duplicate-ledger' && returned) rows.push({ ...rows[0], id: '902' }); return envelope({ records: rows, total: rows.length }) } + if (path === `${DISPATCH}/candidates`) { assert.equal(params.get('warehouseId'), WAREHOUSE); return envelope({ complete: true, snapshotAt: '2026-10-19', candidates: [{ selection: { sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', planPalletId: null, relabelNo: null, warehousePalletId: '100', sourceVersion: 'a'.repeat(64) }, + sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), customerId: CUSTOMER, warehouseId: WAREHOUSE, orderNo: 'LM-A', inboundCode: 'IN-A', palletNo: context.palletCode, destination, availablePackages: fault === 'wrong-candidate' ? 4 : 2, appointmentBatches: [], blockReasons: [] }] }) } + if (path === DISPATCH && method === 'POST') { if (!body.cargo.length) { nextCreated = true; vehicleOwner = 'TR-NEXT'; driverOwner = 'TR-NEXT'; return remember(command('CREATE', {}, true)) }; assert.equal(body.cargo[0].sourceType, 'RETURN_RECEIPT'); assert.equal(body.cargo[0].planPalletId, null); created = true; return remember(command('CREATE')) } + if (path === DISPATCH) return envelope({ records: [{ ...detail(params.get('dispatchNo') === 'DP-NEXT'), warehouseId: WAREHOUSE }] }) + if (path.endsWith('/master-data/vehicles')) { vehiclePlate = body.plateNumber; return envelope({ id: '601' }) } + if (path.endsWith('/fleet/drivers') && method === 'POST') return envelope({ id: '701', recordVersion: 0 }) + if (path.endsWith('/fleet/drivers/701/review')) return envelope({ reviewStatus: 'APPROVED' }) + if (path === `${BASE}/fleet/trips`) { const trips = nextCreated ? [tripDetail(false).trip, tripDetail(true).trip] : [tripDetail(false).trip]; return envelope({ records: trips, page: 1, size: 200, total: trips.length }) } + if (path.endsWith('/fleet/trips/TR-NEW/completion')) return envelope({ canComplete: true, outcomes: [{}], recordVersion: tripVersion }) + if (path.endsWith('/fleet/trips/TR-NEW/complete')) { closed = true; tripVersion++; return remember(tripDetail(false)) } + if (path.endsWith('/resource-occupancy')) return envelope([{ resourceId: params.get('resourceIds'), occupiedTripNo: params.get('kind') === 'VEHICLE' ? vehicleOwner : driverOwner }]) + if (path.endsWith('/resources/release')) { + const prior = ledger.get(ledgerKey) + if (!prior) { if (body.resourceKind === 'VEHICLE') { vehicleReleased = true; vehicleOwner = null } else { driverReleased = true; driverOwner = null }; tripVersion++; ledger.set(ledgerKey, { body: copy(body), data: null }) } + else assert.deepEqual(prior.body, body) + return envelope(tripDetail(false)) + } + if (path.endsWith('/fleet/trips/TR-NEW')) return envelope(tripDetail(false)) + if (path.endsWith('/fleet/trips/TR-NEXT')) return envelope(tripDetail(true)) + if (path.includes('/delivery-orders/') && path.endsWith('/documents')) { + const number = path.split('/delivery-orders/')[1].split('/')[0] + if (method === 'GET') return envelope({ version: 0, categories: [] }) + if (number === 'DL-NEW') newSigned = true; else originalSigned = true + return remember({ version: 1, categories: [{ category: 'RECEIPT', status: 'UPLOADED', fileCount: 1 }], replayed: false }) + } + if (path.endsWith('/status') && path.includes('/delivery-orders/')) { assert.equal(body.expectedVersion, controls.deliveryVersion ?? 0); controls.waitDelivery = false; return envelope({ deliveryStatus: 'DELIVERING' }) } + if (path.includes('/delivery-orders/')) return envelope({ order: order(path.split('/').at(-1)), operations: [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] }) + const next = normalized.startsWith(`${DISPATCH}/DP-NEXT`) + if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] }) + if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops }) + if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] }) + if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2, + ...(fault === 'missing-measurements' ? {} : { palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, deliveryIndex: 1, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] }) + if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] }) + if (method === 'GET' && normalized === `${DISPATCH}/${next ? 'DP-NEXT' : 'DP-NEW'}`) return envelope(detail(next)) + assert.ok(created, `${method} ${raw}`) + if (normalized.endsWith('/cancel')) { nextCancelled = true; vehicleOwner = null; driverOwner = null; return remember({ ...command('CANCEL', {}, true), status: 'CANCELLED' }) } + if (method === 'PUT' && normalized.endsWith('/DP-NEW')) { if (picked) return envelope(null, 'A0409', '已扫码不能编辑'); assert.equal(body.recordVersion, recordVersion); recordVersion++; return remember(command('EDIT')) } + if (normalized.endsWith('/pick') && body.recordVersion !== recordVersion) return envelope(null, 'A0409', '公共版本已变化') + assert.equal(body.recordVersion, recordVersion, raw) + if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) } + if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) } + if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'AFTER_LOADING'; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + if (normalized.endsWith('/transfer-delivery')) { assert.equal(stage, 'AFTER_LOADING'); arranged = true; recordVersion++; return remember(command('ARRANGE_DELIVERY', { dispatchNo: 'DP-NEW', cargoHash: body.cargoHash, outbounds: [outbound()], deliveries: [ref()] })) } + if (normalized.endsWith('/depart')) { assert.equal(arranged, true); departed = true; stage = 'DELIVERING'; recordVersion++; return remember(command('DEPART', { departedAt: '2026-10-19T03:00:00Z', shippedPackages: 2, outbounds: [outbound()], deliveries: [ref()] })) } + throw Error(`Unexpected ${method} ${raw}`) + } } + const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } } + const input = { customerId: CUSTOMER, warehouseId: WAREHOUSE, pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] } + return { context, trip, input, dependencies, requests, checkpoints, relations, masterIds, controls } } const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) -for (const signedPackages of [undefined, null, '5', -1, 1.5]) test(`unknown or invalid original signed quantity ${signedPackages} stops before redispatch`, async () => { - const f = fixture(), save = f.dependencies.saveReceiptAndComplete - f.dependencies.saveReceiptAndComplete = async (...args) => { - const result = await save(...args) - result.order.signedPackages = signedPackages - return result - } - f.dependencies.prepareFleetTrip = () => assert.fail('invalid signed quantity must stop before preparing redispatch') - await assert.rejects(run(f), /stored quantity/) - assert.equal(f.trip.returnRedispatch.originalSignatures.length, 0) -}) - -for (const signedPackages of [undefined, null, '2', 0, 3]) test(`redispatch requires actual numeric two signed boxes: ${signedPackages}`, async () => { - const f = fixture(), save = f.dependencies.saveReceiptAndComplete - f.dependencies.saveReceiptAndComplete = async (...args) => { - const result = await save(...args) - if (args[1].deliveryNo === 'DL-NEW') result.order.signedPackages = signedPackages - return result - } - f.dependencies.finishFleetTrip = () => assert.fail('invalid new signature must not release trip resources') - await assert.rejects(run(f), /two boxes actually signed/) -}) - -test('mark-return uses the selected master ID even without an ordinary inbound relation', async () => { - const f = fixture('no-inbound') - assert.ok(f.relations.every(row => row.inboundOrderId === null)) - assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') - const writes = f.requests.filter(row => row.path.includes('/mark-return')) - assert.equal(writes.length, 1) - assert.equal(writes[0].body.lastMileOrderId, f.masterIds.A) - assert.equal(Object.hasOwn(writes[0].body, 'inboundOrderId'), false) -}) - -for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'foreign-customer', 'foreign-warehouse', 'borrowed-master']) { - test(`return source identity ${fault} stops before mark-return and stock writes`, async () => { - const f = fixture(), request = f.context.request.bind(f.context) - if (fault === 'missing-master') delete f.relations[0].lastMileOrderId - if (fault === 'wrong-delivery') f.relations[0].deliveryNo = 'DL-OTHER' - if (fault === 'wrong-outbound') f.relations[0].outboundNo = 'OUT-OTHER' - if (fault === 'foreign-customer') f.relations[0].customerId = 'OTHER' - if (fault === 'foreign-warehouse') f.relations[0].overseasWarehouseId = 'OTHER' - if (fault === 'borrowed-master') f.context.request = async (...args) => { - const response = await request(...args) - if (args[1].includes(`/orders/${f.masterIds.A}`)) response.body.data.order.id = f.masterIds.B - return response - } - await assert.rejects(run(f), /real master ID|match this candidate/) - assert.ok(f.requests.every(row => row.method === 'GET')) - assert.equal(f.trip.returnRedispatch.exceptionNo, undefined) - }) -} - -for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) { - test('two-box return never borrows an inventory source from another master or a different source type', async () => { - const f = fixture(); mutate(f.relations[0].returnSources[0]) - await assert.rejects(run(f), /Selected master must contain/) - assert.ok(f.requests.every(row => row.method === 'GET')) - }) -} - -test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => { - const f = fixture(); f.input.sourceSelectionRecovery = true - assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') - assert.ok(!f.requests.some(row => row.path?.endsWith('/status?customerId=C'))) -}) - -test('reviewed source recovery refuses WAIT_DELIVERY before any delivery-start or return command', async () => { - const f = fixture(); f.input.sourceSelectionRecovery = true - const request = f.context.request - f.context.request = async (...args) => { - const r = await request(...args) - if (r.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') r.body.data.order.status = 'WAIT_DELIVERY' - return r - } - await assert.rejects(run(f), /never start it again/) - assert.ok(f.requests.every(row => row.method === 'GET')) -}) - -test('fresh return/redispatch chain proves A pending, B complete, then A complete without claiming unique source events', async () => { +test('native one-main multi-source return redispatch executes real APIs through new signature and resource closure', async () => { const f = fixture(), result = await run(f) - assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') - assert.equal(result.sourceEventsVerified, false) - assert.equal(result.readOnlyEvidenceRequired.length, 3) - assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY') - assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED') - assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED') - assert.equal(result.afterRedispatch.departed.shippedPackages, 2) - assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')) - assert.ok(f.checkpoints.some(row => row.requests.at(-1)?.status === 'SENDING' && row.requests.at(-1)?.file?.sha256)) - const uploads = f.trip.returnRedispatch.requests.filter(row => row.file) - assert.ok(uploads.length > 0) - assert.ok(uploads.every(row => Number.isSafeInteger(row.file.sizeBytes) && row.file.sizeBytes > 0 && !Object.hasOwn(row.file, 'bytes'))) + assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3) + assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY'); assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED') + assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED'); assert.equal(result.afterRedispatch.departed.action, 'DEPART'); assert.equal(result.afterRedispatch.departed.result.shippedPackages, 2) + assert.equal(result.inventory.map(s => s.ledger.length).every((n, i) => n === (i === 0 ? 0 : 1)), true) + assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')); assert.ok(f.checkpoints.some(p => p.requests.at(-1)?.status === 'SENDING' && p.requests.at(-1)?.file?.sha256)) + assert.ok(f.requests.findIndex(r => r.path.endsWith('/loading-complete')) < f.requests.findIndex(r => r.path.endsWith('/transfer-delivery'))) + assert.equal(f.requests.some(r => r.path.includes('/pre-arrange')), false) }) -for (const missing of [false, true]) test(`unapproved or missing pre-signature gate cannot sign or close new trip; missing=${missing}`, async () => { - const f = fixture('audit-gate') - if (missing) delete f.dependencies.onBeforeRedispatchSignature - const original = f.dependencies.saveReceiptAndComplete - f.dependencies.saveReceiptAndComplete = async (...args) => { assert.notEqual(args[1].deliveryNo, 'DL-NEW'); return original(...args) } - f.dependencies.finishFleetTrip = () => assert.fail('new trip must not close before audited signature') - await assert.rejects(run(f), missing ? /audit gate is required/ : /Audit gate not approved/) - assert.equal(f.trip.returnRedispatch.preSignatureGate.status, 'WAITING') - assert.equal(f.trip.returnRedispatch.afterRedispatch, undefined) +for (const signedPackages of [undefined, null, '5', -1, 1.5]) test(`invalid original signature quantity ${signedPackages} stops before new Create`, async () => { + const f = fixture(); f.controls.originalQuantity = { override: true, value: signedPackages }; await assert.rejects(run(f), /stored quantity/) + assert.equal(f.trip.returnRedispatch.originalSignatures.length, 0); assert.equal(f.requests.some(r => r.path === DISPATCH && r.method === 'POST'), false) +}) +for (const signedPackages of [undefined, null, '2', 0, 3]) test(`new signature must contain numeric exactly two boxes: ${signedPackages}`, async () => { + const f = fixture(); f.controls.newQuantity = { override: true, value: signedPackages }; await assert.rejects(run(f), /two boxes actually signed/) + assert.equal(f.requests.some(r => r.path.endsWith('/TR-NEW/complete')), false) +}) + +test('actual selected master works without ordinary inbound relationship and uses immutable return receipt selector', async () => { + const f = fixture('no-inbound'); assert.equal((await run(f)).returnReceiptId, '300'); assert.ok(f.relations.every(r => r.inboundOrderId == null)) + assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 2) + const create = f.requests.find(r => r.path === DISPATCH && r.body.cargo.length); assert.equal(create.body.cargo[0].sourceType, 'RETURN_RECEIPT'); assert.equal(create.body.cargo[0].planPalletId, null) +}) +for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'foreign-customer', 'foreign-warehouse', 'borrowed-master']) test(`reject return provenance ${fault} before all writes`, async () => { + const f = fixture(), request = f.context.request + if (fault === 'missing-master') delete f.relations[0].lastMileOrderId + if (fault === 'wrong-delivery') f.relations[0].deliveryNo = 'DL-OTHER' + if (fault === 'wrong-outbound') f.relations[0].outboundNo = 'OUT-OTHER' + if (fault === 'foreign-customer') f.relations[0].customerId = '999' + if (fault === 'foreign-warehouse') f.relations[0].overseasWarehouseId = '999' + if (fault === 'borrowed-master') f.context.request = async (...args) => { const response = await request(...args); if (args[1].includes(`/orders/${f.masterIds.A}`)) response.body.data.order.id = f.masterIds.B; return response } + await assert.rejects(run(f), /real master ID|match this candidate/); assert.ok(f.requests.every(r => r.method === 'GET')) +}) +for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) test('ordinary two-box return cannot borrow a source or native carton', async () => { + const f = fixture(); mutate(f.relations[0].returnSources[0]); await assert.rejects(run(f), /Selected master must contain/); assert.ok(f.requests.every(r => r.method === 'GET')) +}) +for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'missing-measurements', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => { + const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') + if (fault === 'missing-B') assert.ok(f.requests.every(r => r.method === 'GET')) + if (fault === 'missing-measurements') assert.equal(f.requests.some(r => r.path.endsWith('/loading-complete') || r.path.endsWith('/transfer-delivery')), false) +}) +test('a shared formal root stays pending for A while child B completes and finishes only after new A signature', async () => { + const f = fixture('shared-root'), proof = await run(f) + assert.equal(proof.rootsBeforeRedispatch[0].root.order.status, 'PROCESSING') + assert.equal(proof.rootsBeforeRedispatch[0].children.find(c => c.id === '701').status, 'PROCESSING') + assert.equal(proof.rootsBeforeRedispatch[0].children.find(c => c.id === '702').status, 'COMPLETED') + assert.ok(proof.rootsAfterRedispatch.every(s => s.root.order.status === 'COMPLETED' && s.children.every(c => c.status === 'COMPLETED'))) +}) +for (const missing of [false, true]) test(`unapproved or missing readonly audit prevents new signature and closure; missing=${missing}`, async () => { + const f = fixture('audit-gate'); if (missing) delete f.dependencies.onBeforeRedispatchSignature + await assert.rejects(run(f), missing ? /audit gate is required/ : /Audit gate not approved/); assert.equal(f.trip.returnRedispatch.preSignatureGate.status, 'WAITING') + assert.equal(f.requests.some(r => r.method === 'PUT' && r.path.includes('/DL-NEW/documents')), false); assert.equal(f.requests.some(r => r.path.endsWith('/TR-NEW/complete')), false) +}) +test('core producer, pick and successful signature cannot be externally substituted', async () => { + for (const name of ['prepareFleetTrip', 'confirmFleetLoading', 'pickAll', 'saveReceiptAndComplete', 'finishFleetTrip']) { const f = fixture(); f.dependencies[name] = async () => ({ success: true }); await assert.rejects(run(f), /cannot be replaced/); assert.equal(f.requests.length, 0) } +}) +test('native owned departure guard rejects old status and wrong original whole before HTTP', async () => { + const f = fixture(); f.context.createdResources.fleetAcceptance.departure.status = 'IN_TRANSIT'; await assert.rejects(run(f), /owned departure/); assert.equal(f.requests.length, 0) + const other = fixture(); other.input.dispatchNos = ['BORROWED']; await assert.rejects(run(other), /owned departure/); assert.equal(other.requests.length, 0) +}) +test('reviewed source selection retains started deliveries and refuses to repeat WAIT_DELIVERY start', async () => { + const f = fixture(); f.input.sourceSelectionRecovery = true; await run(f); assert.equal(f.requests.some(r => r.path.includes('/status')), false) + const waiting = fixture(); waiting.input.sourceSelectionRecovery = true; waiting.controls.waitDelivery = true; await assert.rejects(run(waiting), /never start it again/); assert.ok(waiting.requests.every(r => r.method === 'GET')) +}) +test('fresh WAIT_DELIVERY start preserves exact server Long version including zero', async () => { + for (const version of [0, '9223372036854775807']) { const f = fixture(); f.controls.waitDelivery = true; f.controls.deliveryVersion = version; await run(f); const started = f.requests.find(r => r.path.includes('/status')); assert.equal(started.body.expectedVersion, version) } +}) +test('fixture source A/B selection refuses borrowed or partial shipments', () => { + const row = name => ({ tail: { orderType: 'FIRST_LEG', sourceInboundId: name, inboundCode: name, packageCount: 4 }, relation: { inboundCode: name, returnSources: [{ maxReturnPackages: 3 }] } }) + assert.throws(() => selectReturnSources([row('A'), row('B')]), /cover each selected source completely/) +}) +test('write-ahead failure sends no HTTP and unknown return preserves original key without restart', async () => { + const disk = fixture(); disk.context.checkpoint = async () => { throw Error('disk full') }; await assert.rejects(run(disk), /disk full/); assert.equal(disk.requests.length, 0) + const f = fixture('unknown-return'); await assert.rejects(run(f), /connection lost/); const last = f.checkpoints.at(-1).requests.at(-1); assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey) + const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) +for (const [name, response] of Object.entries({ http0: { http: 0, body: null }, server500: { http: 500, body: { code: 'B0001' }, requestId: 'r', traceId: 't' }, proxy502: { http: 502, body: null }, malformed200: { http: 200, body: { unexpected: true }, requestId: 'r', traceId: 't' }, htmlFailure: { http: 403, body: 'blocked', requestId: 'r', traceId: 't' }, uncorrelatedSuccess: { http: 200, body: { code: '200', data: {} } } })) test(`ambiguous ${name} stops without automatic replay`, async () => { + const f = fixture(), request = f.context.request; f.context.request = async (...args) => { if (args[1].includes('/mark-return')) { f.requests.push({ method: args[0], path: args[1], body: copy(args[2]) }); return response }; return request(...args) } + await assert.rejects(run(f), /outcome unknown/); const last = f.checkpoints.at(-1).requests.at(-1); assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey) + assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 1); const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) +test('unknown multipart writes retain file hash and stop before return stock receipt', async () => { + const f = fixture(); f.context.requestMultipart = async (method, path, file) => { f.requests.push({ method, path, file: file.name }); return { http: 0, body: null } } + await assert.rejects(run(f), /outcome unknown/); const entry = f.checkpoints.at(-1).requests.at(-1); assert.equal(entry.status, 'UNKNOWN_RESULT'); assert.match(entry.file.sha256, /^[a-f0-9]{64}$/) + assert.equal(f.requests.some(r => r.path.includes('/exception-orders/') && r.path.includes('/complete')), false) +}) +test('correlated business conflict is retained as a definite rejection without retry', async () => { + const f = fixture(), request = f.context.request; f.context.request = async (...args) => { if (args[1].includes('/mark-return')) { f.requests.push({ method: args[0], path: args[1], body: copy(args[2]) }); return { http: 409, body: { code: 'A0409' }, requestId: 'r', traceId: 't' } }; return request(...args) } + await assert.rejects(run(f), /HTTP failed/); assert.equal(f.checkpoints.at(-1).requests.at(-1).status, 'RESPONDED'); assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 1) }) for (const fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test(`file gate requires exact checkpoint and hashed source audit: ${fault}`, async () => { @@ -206,97 +249,3 @@ for (const fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test rmSync(root, { recursive: true, force: true }) } }) -for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'wrong-candidate', 'rewritten-original']) { - test(`rejects ${fault} instead of declaring redispatch complete`, async () => { - const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') - if (fault === 'missing-B') assert.ok(f.requests.every(row => row.method === 'GET')) - }) -} -test('unknown return outcome persists the exact original key and cannot automatically restart', async () => { - const f = fixture('unknown-return'); await assert.rejects(run(f), /connection lost/) - const last = f.checkpoints.at(-1).requests.at(-1) - assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey) - const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) -}) -test('pre-send checkpoint failure cannot send even the first HTTP request', async () => { - const f = fixture(); f.context.checkpoint = async () => { throw Error('disk full') } - await assert.rejects(run(f), /disk full/); assert.equal(f.requests.length, 0) -}) -test('fixture selection refuses partial coverage or borrowed source shipments', () => { - const row = name => ({ tail: { orderType: 'FIRST_LEG', sourceInboundId: name, inboundCode: name, packageCount: 4 }, - relation: { inboundCode: name, returnSources: [{ maxReturnPackages: 3 }] } }) - assert.throws(() => selectReturnSources([row('A'), row('B')]), /cover each selected source completely/) -}) - -test('WAIT_DELIVERY start submits exactly the version read from the current delivery', async () => { - const f = fixture(), original = f.context.request.bind(f.context) - let started = false - f.context.request = async (method, path, body, ...args) => { - if (method === 'PUT' && path.includes('/delivery-orders/DL-ORIGINAL/status')) { - assert.equal(body.expectedVersion, 17); assert.equal(body.status, 'DELIVERING'); started = true - f.requests.push({ method, path, body }) - return { http: 200, body: { code: '200', data: { deliveryStatus: 'DELIVERING' } }, requestId: 'r', traceId: 't' } - } - const response = await original(method, path, body, ...args) - if (!started && response.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') Object.assign(response.body.data.order, { status: 'WAIT_DELIVERY', version: '17' }) - if (path.includes('/return-inbound-options')) assert.equal(started, true) - return response - } - await run(f) - assert.equal(f.requests.filter(row => row.method === 'PUT' && row.path.includes('/status')).length, 1) -}) - -for (const multipart of [false, true]) test(`http:0 records UNKNOWN_RESULT and stops without retry; multipart=${multipart}`, async () => { - const f = fixture(), original = f.context.request.bind(f.context) - if (multipart) f.context.requestMultipart = async (method, path, file) => { - f.requests.push({ method, path, file: file.name }); return { http: 0, body: null, requestId: null, traceId: null } - } - else f.context.request = async (method, path, body, ...args) => { - if (path.includes('/exception-orders/') && path.includes('/complete')) { - f.requests.push({ method, path, body: structuredClone(body) }); return { http: 0, body: null, requestId: null, traceId: null } - } - return original(method, path, body, ...args) - } - await assert.rejects(run(f), /transport outcome unknown/) - const last = f.checkpoints.at(-1).requests.at(-1) - assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, 0) - if (multipart) assert.match(last.file.sha256, /^[a-f0-9]{64}$/) - else assert.ok(last.body.idempotencyKey) - assert.equal(f.requests.filter(row => row.path === last.path && row.method === last.method).length, 1) - const count = f.requests.length - await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) -}) - -for (const [name, response] of Object.entries({ - server500: { http: 500, body: { code: 'B0001', message: 'Internal error' }, requestId: 'r', traceId: 't' }, - proxy502: { http: 502, body: null, requestId: null, traceId: null }, - malformed200: { http: 200, body: { unexpected: true }, requestId: 'r', traceId: 't' }, - htmlFailure: { http: 403, body: 'proxy blocked', requestId: 'r', traceId: 't' }, - uncorrelatedSuccess: { http: 200, body: { code: '200', data: { status: 'COMPLETED' } }, requestId: null, traceId: null }, -})) test(`ambiguous ${name} persists UNKNOWN_RESULT without replay or further requests`, async () => { - const f = fixture(), original = f.context.request.bind(f.context) - f.context.request = async (method, path, body, ...args) => { - if (path.includes('/mark-return')) { f.requests.push({ method, path, body: structuredClone(body) }); return response } - return original(method, path, body, ...args) - } - await assert.rejects(run(f), /outcome unknown/) - const last = f.checkpoints.at(-1).requests.at(-1) - assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, response.http) - assert.ok(last.body.idempotencyKey) - assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1) - assert.equal(f.requests.at(-1).path, last.path) - const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) -}) - -test('correlated explicit business rejection remains RESPONDED and also stops without replay', async () => { - const f = fixture(), original = f.context.request.bind(f.context) - f.context.request = async (method, path, body, ...args) => { - if (path.includes('/mark-return')) { - f.requests.push({ method, path, body }); return { http: 409, body: { code: 'A0409', message: 'version conflict' }, requestId: 'r', traceId: 't' } - } - return original(method, path, body, ...args) - } - await assert.rejects(run(f), /HTTP failed/) - assert.equal(f.checkpoints.at(-1).requests.at(-1).status, 'RESPONDED') - assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1) -}) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index ddfafe976..953e9a8d3 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -3,10 +3,11 @@ import {join,resolve} from 'node:path' import {redactEvidence} from '../fba-core-regression-test-env.mjs' import { randomUUID, createHash } from 'node:crypto' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' -import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './phase5-overseas-fulfillment.mjs' +import { version, createBody, candidatePage, readDispatch, replay as replayCommand } from './grouped-dispatch-acceptance.mjs' const BASE = '/api/platform/v1/last-leg' -const id = value => value == null ? '' : String(value) +const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Unsafe server ID; retain Long string'); return value == null ? '' : String(value) } const key = () => `redispatch-${randomUUID()}` const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}` const ok = response => response?.http === 200 && String(response?.body?.code) === '200' @@ -36,10 +37,14 @@ export function selectReturnSources(rows) { /** Owned departure only; reviewed source-selection recovery preserves prior starts. Requests are checkpointed before dispatch. */ export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) { const run = context.createdResources?.fleetAcceptance + if (Object.keys(dependencies).some(name => !['wait', 'onBeforeRedispatchSignature'].includes(name))) throw Error('Only readonly audit and wait observers may be supplied; native production, picking and signature cannot be replaced') + const departure = run?.departure if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker - || run.tripNo !== trip.tripNo || run.departure?.status !== 'IN_TRANSIT' || !input?.pickerToken + || run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'DEPART' + || !departure.result?.departedAt || !input?.pickerToken || id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId) - || JSON.stringify((run.departure.dispatches || []).map(row => row.dispatchNo).sort()) !== JSON.stringify([...(input.dispatchNos || [])].sort()) + || JSON.stringify([departure.dispatchNo]) !== JSON.stringify([...(input.dispatchNos || [])].sort()) + || JSON.stringify((departure.result.deliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].sort()) || !input.deliveryNos?.length || trip.returnRedispatch || typeof context.checkpoint !== 'function') throw Error('Redispatch requires a new owned departure, current PDA session and durable checkpoints; no automatic restart') const proof = trip.returnRedispatch = { marker: context.marker, status: 'CHECKING', requests: [], returnPackages: 2, @@ -47,15 +52,18 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, 'After redispatch signature and exact replay: A and B each have exactly one OVERSEAS_DELIVERY_COMPLETED outbox event by customer+sourceInboundId; inspect delivery ACK and domestic inbound completion separately', 'Join return receipt ledger_id and source_outbound_no to immutable RETURN_STOCK_IN; join new picks by return_receipt_id; original shipment and signed facts remain unchanged'] } const check = (label, condition) => { if (!condition) throw Error(label) } + let stopped = false const io = Object.create(context) io.assert = (...args) => { context.assert(...args); if (!args[3]) throw Error(args[1]) } async function invoke(method, path, body, token, file, options) { + if (stopped) throw Error('Unknown prior outcome; preserve original command and stop') const entry = { method, path, body: copy(body), actor: token ? 'PDA' : 'PLATFORM', status: 'SENDING' } if (file) entry.file = { name: file.name, type: file.type, sizeBytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } proof.requests.push(entry); await context.checkpoint() try { const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options) if (!definiteResponse(response)) { + stopped = true; proof.outcomeUnknown = true Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) await context.checkpoint() @@ -66,16 +74,17 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, await context.checkpoint() check(`Missing HTTP correlation: ${method} ${path}`, response?.requestId && response?.traceId) return response - } catch (error) { if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() } throw error } + } catch (error) { if (entry.status === 'SENDING') { stopped = true; proof.outcomeUnknown = true; entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; await context.checkpoint() } throw error } } io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options) io.requestMultipart = (method, path, file) => invoke(method, path, undefined, undefined, file) async function request(method, path, body, token) { const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data } - const deps = { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, pickAll, saveReceiptAndComplete, - onBeforeRedispatchSignature: context.context?.onBeforeRedispatchSignature, - wait: ms => new Promise(resolve => setTimeout(resolve, ms)), ...dependencies } + const deps = { onBeforeRedispatchSignature: dependencies.onBeforeRedispatchSignature || context.context?.onBeforeRedispatchSignature, + wait: dependencies.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))) } + const native = { command: (method, path, body, label) => request(method, path, body), check: (label, response, passed) => check(label, passed) } + const replay = async (path, body, first, label) => { const again = await replayCommand(native, path, body, first, label); check(label, again.replayed === true); return again } const customerId = id(input.customerId), warehouseId = id(input.warehouseId) const tail = relation => request('GET', scoped(`${BASE}/orders/${relation.lastMileOrderId}`, customerId)) const delivery = number => request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}`, customerId)) @@ -95,19 +104,52 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, } throw Error(allSigned ? 'Domestic source completion has not converged after redispatch signature' : 'Independent source B domestic completion did not converge') } + async function roots(a, b, beforeSignature) { + const snapshots = [] + const known = value => ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED'].includes(value) + for (const [name, selected] of [['A', a], ['B', b]]) { + const sourceId = id(selected.tail.sourceOrderId) + check('Original source order provenance is required for root and child guards', /^[1-9]\d*$/.test(sourceId)) + const rawPath = order => `/api/platform/v1/orders/first-leg/${encodeURIComponent(order)}?${new URLSearchParams({ tenantId: customerId })}` + const source = await request('GET', rawPath(sourceId)), order = source?.order + const rootId = order?.orderRole === 'MASTER' ? id(order.id) : id(order?.rootOrderId) + check('Original source and fulfillment root belong to current customer', id(order?.id) === sourceId && id(order.customerId) === customerId && id(order.tenantId) === customerId + && known(order.status) && /^[1-9]\d*$/.test(rootId) && (order.orderRole === 'MASTER' || order.orderRole === 'CHILD' && id(order.parentOrderId) === rootId)) + const root = rootId === sourceId ? source : await request('GET', rawPath(rootId)) + const tree = await request('GET', `/api/platform/v1/orders/${rootId}/fulfillment-tree?${new URLSearchParams({ tenantId: customerId })}`) + check('Formal root and explicit child list remain customer-owned', id(root.order?.id) === rootId && root.order.orderRole === 'MASTER' && id(root.order.customerId) === customerId && id(root.order.tenantId) === customerId && known(root.order.status) + && id(tree.parentOrderId) === rootId && tree.parentOrderNo === root.order.orderNo && Array.isArray(tree.children) && Array.isArray(root.statusHistory)) + const children = [] + for (const row of tree.children.filter(row => id(row.orderId) !== rootId)) { + const child = await request('GET', rawPath(id(row.orderId))) + check('Every formal child stays linked to the actual root', id(child.order?.id) === id(row.orderId) && child.order.orderRole === 'CHILD' + && id(child.order.rootOrderId) === rootId && id(child.order.parentOrderId) === rootId && id(child.order.customerId) === customerId && id(child.order.tenantId) === customerId && known(child.order.status) && child.order.orderNo === row.orderNo) + children.push(child.order) + } + check('Original source is present in its real formal root tree', sourceId === rootId || children.some(child => id(child.id) === sourceId)) + const pending = children.some(child => !['COMPLETED', 'CANCELLED'].includes(child.status)) + const completed = root.statusHistory.some(event => event.action === 'OVERSEAS_DELIVERED') && !pending + check('Root cannot complete ahead of valid children or overseas delivery fact', completed + ? root.order.status === 'COMPLETED' && root.statusHistory.some(event => event.action === 'FULFILLMENT_COMPLETED') + : root.order.status !== 'COMPLETED' && !root.statusHistory.some(event => event.action === 'FULFILLMENT_COMPLETED')) + if (beforeSignature && name === 'A') check('Incomplete A child cannot be completed before redispatch signature', order.status !== 'COMPLETED') + snapshots.push({ source: name, sourceOrder: order, root, tree, children }) + } + return snapshots + } try { const rows = [] proof.originalDeliveries = [] for (const number of [...new Set(input.deliveryNos)]) { const current = await delivery(number) - check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo)) + check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo) + && id(current.order.customerId) === customerId && id(current.order.overseasWarehouseId) === warehouseId) proof.originalDeliveries.push(copy(current.order)) if (input.sourceSelectionRecovery) check('Reviewed source recovery must retain the already started delivery; never start it again', current.order.status === 'DELIVERING') if (current.order.status === 'WAIT_DELIVERY') { - check('Delivery status mutation requires the current version', Number.isSafeInteger(Number(current.order.version)) - && current.order.version != null && String(current.order.version).trim() !== '' && Number(current.order.version) >= 0) + version(current.order.version) const started = await request('PUT', scoped(`${BASE}/delivery-orders/${number}/status`, customerId), { - status: 'DELIVERING', expectedVersion: Number(current.order.version), remark: `${context.marker} current-run delivery underway`, + status: 'DELIVERING', expectedVersion: version(current.order.version), remark: `${context.marker} current-run delivery underway`, }) check('Return candidates are opened only after formal delivery start', started.deliveryStatus === 'DELIVERING') } @@ -132,10 +174,13 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, && /^[1-9][0-9]*$/.test(id(row.inventoryPositionId)) && row.returnCartonId == null && Number.isSafeInteger(Number(row.maxReturnPackages)) && Number(row.maxReturnPackages) >= 2) check('Selected master must contain the actual ordinary stock source for the two returned boxes', Boolean(source)) - const marked = await request('POST', scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId), { + const markPath = scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId) + proof.markReturnRequest = { lastMileOrderId: id(a.relation.lastMileOrderId), reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RET`, items: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, packageCount: 2 }], idempotencyKey: key(), - }) + } + const marked = await request('POST', markPath, proof.markReturnRequest) + await replay(markPath, proof.markReturnRequest, marked, 'Mark-return original command replay') proof.exceptionNo = marked.exceptionNo const exceptionPath = `${BASE}/exception-orders/${encodeURIComponent(marked.exceptionNo)}` const resource = `/api/platform/v1/overseas-warehouse-resources/${warehouseId}` @@ -145,6 +190,34 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, const locationPage = await request('GET', `${resource}/locations?page=1&size=200`) const location = (Array.isArray(locationPage) ? locationPage : locationPage.records || []).find(row => id(row.id) === id(pallet.locationId)) check('New return pallet has a real same-warehouse location', id(pallet.warehouseId) === warehouseId && location?.locationCode && pallet.palletCode === palletCode) + const paged = async path => { + const rows = []; let total + for (let page = 1; ; page++) { + const current = await request('GET', `${path}&page=${page}&size=200`) + check('Inventory pagination must remain complete and stable', Array.isArray(current?.records) && Number.isSafeInteger(Number(current.total)) && Number(current.total) >= 0 + && (total === undefined || total === Number(current.total)) && (current.records.length > 0 || rows.length === Number(current.total))) + total ??= Number(current.total); rows.push(...current.records); if (rows.length >= total) { check('Inventory pagination cannot omit or duplicate rows', rows.length === total && new Set(rows.map(row => id(row.id))).size === rows.length); return rows } + } + } + const inventory = async label => { + const filters = new URLSearchParams({ customerId, overseasWarehouseId: warehouseId, inboundCode: a.relation.inboundCode }) + const balances = await paged(`${BASE}/inventory/balances?${filters}`), positions = [] + for (const balance of balances) { + check('Returned balance belongs to original customer and warehouse', id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === warehouseId) + const rows = await request('GET', `${BASE}/inventory/balances/${id(balance.id)}/positions?${new URLSearchParams({ customerId, palletNo: palletCode })}`) + check('Actual return positions are explicit rows', Array.isArray(rows)) + for (const row of rows.filter(row => id(row.palletId) === palletId)) { + check('Returned stock identity stays within original customer warehouse and inbound', id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode) + positions.push(row) + } + } + const ledger = await paged(`${BASE}/inventory/ledger?${filters}&${new URLSearchParams({ businessType: 'RETURN_STOCK_IN', businessOrderNo: marked.exceptionNo, outboundOrderNo: a.delivery.outboundNo, palletNo: palletCode })}`) + check('Return stock-in ledger is immutable original-shipment evidence', ledger.every(row => id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId + && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode && row.businessType === 'RETURN_STOCK_IN' && row.businessOrderNo === marked.exceptionNo && row.outboundOrderNo === a.delivery.outboundNo)) + const result = { label, positions, ledger, available: positions.reduce((n, row) => n + Number(row.availablePackages), 0), picked: positions.reduce((n, row) => n + Number(row.pickedPackages), 0) } + proof.inventory ||= []; proof.inventory.push(copy(result)); return result + } + const empty = await inventory('BEFORE_RETURN'); check('Fresh target pallet contains no previous return stock', empty.available === 0 && empty.picked === 0 && empty.ledger.length === 0) const started = await request('POST', scoped(`${exceptionPath}/start-processing`, customerId), { expectedVersion: marked.version, idempotencyKey: key(), handlingAction: '实际退回2箱' }) const uploaded = await io.requestMultipart('POST', scoped(`${exceptionPath}/evidence`, customerId), { name: `${context.marker}-return.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} synthetic evidence of 2 returned boxes`), @@ -155,13 +228,20 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, targetPalletId: palletId, targetPalletNo: palletCode, targetLocationId: location.id, targetLocationCode: location.locationCode }] } const restored = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) check('Exactly two boxes restored', restored.status === 'COMPLETED' && Number(restored.restoredPackages) === 2) - const replay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) - check('Same return key replays the same adjustment', replay.replayed === true && id(replay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId)) + const stockIn = await inventory('RETURN_STOCK_IN') + check('Actual two returned boxes produce exactly one stock-in ledger', stockIn.available === 2 && stockIn.picked === 0 && stockIn.ledger.length === 1 && Number(stockIn.ledger[0].deltaPackages) === 2) + const restoredReplay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) + check('Same return key replays the same adjustment', restoredReplay.replayed === true && id(restoredReplay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId)) + const stockReplay = await inventory('RETURN_REPLAY') + check('Return replay cannot duplicate stock or ledger', stockReplay.available === stockIn.available && stockReplay.picked === stockIn.picked && JSON.stringify(stockReplay.ledger) === JSON.stringify(stockIn.ledger) && JSON.stringify(stockReplay.positions) === JSON.stringify(stockIn.positions)) + const history = await request('GET', scoped(exceptionPath, customerId)) + check('Owned exception exposes the actual unique returned receipt business number', history?.order?.exceptionNo === marked.exceptionNo && id(history.order.customerId) === customerId + && history.order.outboundNo === a.delivery.outboundNo && history.returnReceipts?.length === 1 && history.returnReceipts[0].receiptNo && Number(history.returnReceipts[0].receivedPackages) === 2) proof.returned = { palletId, palletCode, adjustmentId: restored.inventoryAdjustmentId } proof.originalSignatures = [] for (const [index, number] of input.deliveryNos.entries()) { const current = await delivery(number) - const signed = await deps.saveReceiptAndComplete(io, current.order, customerId, 171 + index) + const signed = await saveReceiptAndComplete(io, current.order, customerId, 171 + index) check('Original remaining cargo actually signed with a stored quantity', signed?.order?.signedAt && Number.isSafeInteger(signed.order.signedPackages) && signed.order.signedPackages >= 0) proof.originalSignatures.push(copy(signed.order)) @@ -170,35 +250,42 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, check('A remains incomplete while independently complete B is delivered', !beforeA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && beforeB.order.subOrderStatus?.includes('DELIVERY:DELIVERED')) proof.beforeRedispatch = { A: beforeA, B: beforeB } proof.domesticBeforeRedispatch = await domesticBoundary(a, b, false) - const candidates = await request('GET', `${BASE}/dispatches/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: a.relation.inboundCode })}`) - const matches = candidates.filter(row => row.sourceType === 'RETURN_RECEIPT' && id(row.warehousePalletId) === palletId && row.returnReceiptNo && id(row.orderId) === id(a.relation.lastMileOrderId)) - check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].packageCount) === 2 && !matches[0].planPalletId) - const selected = matches[0], draftToken = key() - const container = await request('POST', `${BASE}/dispatches/drafts/containers`, { draftToken, capacity: 30 }) - await request('POST', `${BASE}/dispatches/draft-locks/lock`, { sourceType: 'RETURN_RECEIPT', returnReceiptId: selected.returnReceiptId, draftToken, draftContainerId: container.id }) - const arrange = { planPalletIds: [], returnReceiptIds: [selected.returnReceiptId], draftToken } - const arranged = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange) - check('Redispatch is a new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo) && Number(arranged.packageCount) === 2) - const arrangedReplay = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange) - check('Draft replay keeps the same redispatch', arrangedReplay.dispatchNo === arranged.dispatchNo) - const newTrip = await deps.prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId) - proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = selected.returnReceiptNo; proof.redispatchNo = arranged.dispatchNo - const tripDetail = await request('GET', `${BASE}/fleet/trips/${newTrip.tripNo}`) - const transfer = await request('POST', `${BASE}/dispatches/${arranged.dispatchNo}/transfer-delivery`, { - idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), deliveryMethod: 'TRUCK_PALLET', - pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', - vehiclePlate: tripDetail.trip.vehiclePlate, driverName: tripDetail.trip.driverName, driverPhone: tripDetail.trip.driverPhone, appointmentAccount: 'SELF', - fees: [{ feeType: 'DELIVERY_FEE', feeName: '退回重派验收费用', direction: 'RECEIVABLE', currency: 'CNY', amount: 1 }], remark: context.marker, - }) - check('Redispatch produces a new single destination delivery', transfer.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo)) - check('Fresh return stock can be picked via PDA', await deps.pickAll(io, input.pickerToken, arranged.dispatchNo)) - const recordVersion = await deps.confirmFleetLoading(io, module, newTrip) - const departBody = { recordVersion, idempotencyKey: key(), remark: '退回2箱重新实际发车' } + proof.rootsBeforeRedispatch = await roots(a, b, true) + const candidates = await candidatePage(native, warehouseId, { orderNo: a.relation.inboundCode }) + const matches = candidates.filter(row => row.selection?.sourceType === 'RETURN_RECEIPT' && id(row.selection.warehousePalletId) === palletId + && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId && row.orderNo === a.tail.orderNo && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode) + check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].availablePackages) === 2 && matches[0].selection.planPalletId == null) + const selected = matches[0] + proof.createRequest = createBody(context.marker, 'RETURN-REDISPATCH', [selected]) + const arranged = await request('POST', `${BASE}/dispatches`, proof.createRequest) + check('Redispatch is a native new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo) + && arranged.action === 'CREATE' && arranged.status === 'ACTIVE' && arranged.stage === 'BEFORE_LOADING' && Number(arranged.counts?.packageCount) === 2) + await replay(`${BASE}/dispatches`, proof.createRequest, arranged, 'Native Create original-key replay keeps the same redispatch') + const actual = await readDispatch(native, arranged.dispatchNo) + check('Created redispatch readback preserves exact returned receipt customer and stock identity', actual.warehouseId === warehouseId && actual.items.items.length === 1 + && actual.items.items[0].sourceKey === selected.sourceKey && id(actual.items.items[0].returnReceiptId) === id(selected.selection.returnReceiptId) + && id(actual.items.items[0].warehousePalletId) === palletId && id(actual.items.items[0].customerId) === customerId && Number(actual.items.items[0].scheduledPackages) === 2) + const newTrip = await prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId) + proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = history.returnReceipts[0].receiptNo; proof.returnReceiptId = selected.selection.returnReceiptId; proof.redispatchNo = arranged.dispatchNo + check('Fresh return stock can be picked via PDA', await pickAll(io, input.pickerToken, arranged.dispatchNo)) + const picked = await inventory('RETURN_PICKED') + check('Actual returned stock is picked once without rewriting the stock-in ledger', picked.available === 0 && picked.picked === 2 && JSON.stringify(picked.ledger) === JSON.stringify(stockIn.ledger)) + await confirmFleetLoading(io, module, newTrip, input.pickerToken, input.actualMeasurements) + const transfer = await arrangeDispatchDelivery(io, arranged.dispatchNo) + check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo) + && id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2) + const current = await request('GET', `${BASE}/dispatches/${encodeURIComponent(arranged.dispatchNo)}`) + check('New whole is loaded and fully arranged before actual departure', current.status === 'ACTIVE' && current.stage === 'AFTER_LOADING' && Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 0) + const departBody = { recordVersion: version(current.recordVersion), idempotencyKey: key(), remark: '退回2箱重新实际发车' } const departPath = `/api/pda/v1/last-leg/dispatches/${arranged.dispatchNo}/depart` const departed = await request('POST', departPath, departBody, input.pickerToken) - check('New DEPART ships exactly two returned boxes', departed.tripNo === newTrip.tripNo && Number(departed.shippedPackages) === 2) + check('New DEPART ships exactly two returned boxes', departed.dispatchNo === arranged.dispatchNo && departed.action === 'DEPART' && departed.status === 'ACTIVE' + && departed.stage === 'DELIVERING' && departed.result?.departedAt && Number(departed.result.shippedPackages) === 2 + && departed.result.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === transfer.deliveries[0].deliveryNo) const departedAgain = await request('POST', departPath, departBody, input.pickerToken) - check('Departure replay does not duplicate shipping', departedAgain.replayed === true && Number(departedAgain.shippedPackages) === 2) + const facts = value => { const result = copy(value); delete result.replayed; return JSON.stringify(result) } + check('Departure replay does not duplicate shipping', departedAgain.replayed === true && facts(departedAgain) === facts(departed)) + const shipped = await inventory('RETURN_DEPARTED'); check('Returned stock ships once and retains the original immutable stock-in ledger', shipped.available === 0 && shipped.picked === 0 && JSON.stringify(shipped.ledger) === JSON.stringify(stockIn.ledger)) const newDelivery = await delivery(transfer.deliveries[0].deliveryNo) proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo, departed, originalSignatureCount: proof.originalSignatures.length } @@ -209,7 +296,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, && audit.sourceBEventCount === 1 && /^[a-f0-9]{64}$/.test(audit.auditEvidenceSha256 || '')) Object.assign(proof.preSignatureGate, { status: 'VERIFIED', audit }) await context.checkpoint() - const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271) + const newSignature = await saveReceiptAndComplete(io, newDelivery.order, customerId, 271) check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && newSignature.order.signedPackages === 2) const afterA = await tail(a.relation), afterB = await tail(b.relation) check('A completes only after redispatch; B remains complete', afterA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && afterB.order.subOrderStatus === beforeB.order.subOrderStatus) @@ -219,7 +306,8 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, } proof.afterRedispatch = { A: afterA, B: afterB, newSignature, departed } proof.domesticAfterRedispatch = await domesticBoundary(a, b, true) - proof.closure = await deps.finishFleetTrip(io, module, newTrip) + proof.rootsAfterRedispatch = await roots(a, b, false) + proof.closure = await finishFleetTrip(io, module, newTrip) check('New trip cargo and resource lifecycle completes', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') proof.status = 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'; proof.sourceEventsVerified = false await context.checkpoint(); return proof diff --git a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs index ab09210bf..d60342863 100644 --- a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs @@ -5,10 +5,10 @@ import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } fro const copy = value => value === undefined ? undefined : structuredClone(value) const prefix = 'platform:fleet:trip:' function fixture(fault, multiDestination = false) { - const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map() + const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT', dispatchNo: 'DP-OWN' }, calls = [], rows = [], histories = new Map(), keys = new Map() const shares = [ { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] }, - { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] }, + { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] }, ] const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' } const depart = { recordVersion: 7, action: 'DEPART', snapshot: { @@ -17,8 +17,8 @@ function fixture(fault, multiDestination = false) { confirmedLoad: { manifestHash: 'original-manifest', pallets: [ { warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }], cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5, destinationKey: 'FBA:A' }, - { warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] }, - externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND', + { warehousePalletId: 'P2', dispatchNo: 'DP-OWN', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] }, + externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', customerId: 'C2', status: 'BOUND', receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } } const load = depart.snapshot.confirmedLoad if (multiDestination) { @@ -57,7 +57,7 @@ function fixture(fault, multiDestination = false) { allocations: fault === 'invented allocation' ? [{ businessNo: 'DP-OWN', amount: amount / 100 }] : [] } return { currency, directCost: amount / 100, ownCost: own / 100, externalCost: external / 100, allocations: [ { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, amount: own / 100 }, - { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL', + { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL', palletSpaces: 1, amount: (external + (fault === 'lost cent' ? 1 : 0)) / 100 }] } }) } } @@ -71,7 +71,8 @@ function fixture(fault, multiDestination = false) { } const context = { marker: 'CURRENT-RUN', token: 'offline-writer', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'CURRENT-RUN', tripNo: trip.tripNo, - departure: { tripNo: trip.tripNo, status: 'IN_TRANSIT', departedAt: depart.snapshot.trip.departedAt } } }, + departure: { dispatchNo: trip.dispatchNo, status: 'ACTIVE', stage: 'DELIVERING', action: 'DEPART', + result: { departedAt: depart.snapshot.trip.departedAt } } } }, assert(_module, label, _response, condition) { assert.ok(condition, label) }, async checkpoint() {}, async request(method, path, body, token) { calls.push({ method, path, body: copy(body), actor: token }) @@ -178,6 +179,23 @@ test('missing current-run membership or permission session fails before HTTP', a } }) +test('cost execution requires the current single-dispatch native departure, without a legacy Fleet result alias', async () => { + for (const mutate of [ + f => { f.context.createdResources.fleetAcceptance.departure = { tripNo: f.trip.tripNo, status: 'IN_TRANSIT', departedAt: '2026-10-03T01:00:00Z' } }, + f => { f.context.createdResources.fleetAcceptance.departure.dispatchNo = 'OTHER' }, + f => { f.context.createdResources.fleetAcceptance.departure.stage = 'AFTER_LOADING' }, + f => { f.context.createdResources.fleetAcceptance.departure.result.departedAt = null }, + f => { f.inputs.shares[1].dispatchNo = 'SECOND-DISPATCH' }, + ]) { + const f = fixture(); mutate(f) + await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)) + assert.equal(f.calls.length, 0) + } + const f = fixture() + f.context.createdResources.fleetAcceptance.departure.result.departedAt = '2026-10-03T01:00:00' + assert.equal((await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)).status, 'VERIFIED') +}) + test('unknown HTTP outcome retains original key and does not retry or hide failure', async () => { const f = fixture(), original = f.context.request f.context.request = async (...args) => { diff --git a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs index 6d22fa726..65d2c91ba 100644 --- a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs @@ -12,6 +12,8 @@ const cents = value => { if (!match) throw new Error('成本金额不是非负两位小数') return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0')) } +const departureMillis = value => typeof value === 'string' && value + ? Date.parse(/(?:Z|[+-]\d\d:\d\d)$/i.test(value) ? value : `${value}Z`) : NaN export const TRIP_COST_EXPECTATIONS = Object.freeze({ initialCny: '100.01', finalCny: '100.03', usd: '13.07', rows: 2, history: { CNY: ['CREATE', 'UPDATE'], USD: ['CREATE'] }, allocationMethod: 'SAME_ROUTE_PALLET_SPACES' }) @@ -38,11 +40,13 @@ export function expectedAllocations(amount, shares) { export async function verifyFleetTripCosts(context, module, tripProof, inputs = {}) { const run = context.createdResources?.fleetAcceptance, shares = inputs.shares if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker - || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || run.departure?.tripNo !== tripProof.tripNo - || run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt || tripProof.costAcceptance + || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || !tripProof.dispatchNo + || run.departure?.dispatchNo !== tripProof.dispatchNo || run.departure.action !== 'DEPART' + || run.departure.status !== 'ACTIVE' || run.departure.stage !== 'DELIVERING' + || !Number.isFinite(departureMillis(run.departure.result?.departedAt)) || tripProof.costAcceptance || !inputs.listOnlyToken || !inputs.deniedToken || inputs.listOnlyToken === inputs.deniedToken || !Array.isArray(shares) || !shares.length || new Set(shares.map(row => row.businessNo)).size !== shares.length - || shares.some(row => !row.businessNo || !row.dispatchNo || !['OWN', 'EXTERNAL'].includes(row.sourceType) + || shares.some(row => !row.businessNo || row.dispatchNo !== tripProof.dispatchNo || !['OWN', 'EXTERNAL'].includes(row.sourceType) || !Number.isSafeInteger(row.palletSpaces) || row.palletSpaces < 1 || !Array.isArray(row.warehousePalletIds) || !row.warehousePalletIds.length || row.warehousePalletIds.some(id => !id))) throw new Error('车次成本验收须提供本轮唯一真实DEPART车次、独立托位预期及两类权限会话;禁止借旧样本或重复开始') @@ -113,7 +117,8 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs = && history.records.filter(row => row.action === 'DEPART').length === 1) departure = history.records.find(row => row.action === 'DEPART'); proof.departure = clone(departure) const snapshot = departure.snapshot, load = snapshot?.confirmedLoad - check('发车实物与本轮车次绑定', snapshot?.trip?.tripNo === tripProof.tripNo && snapshot.trip.departedAt === run.departure.departedAt + check('发车实物与本轮车次绑定', snapshot?.trip?.tripNo === tripProof.tripNo + && departureMillis(snapshot.trip.departedAt) === departureMillis(run.departure.result.departedAt) && load?.manifestHash && load.pallets?.length > 0 && load.cargo?.length > 0) const ids = shares.flatMap(row => row.warehousePalletIds).map(String) check('独立预期覆盖全部实物且没有重叠托盘', new Set(ids).size === ids.length diff --git a/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs index 18334ea93..488c27349 100644 --- a/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs @@ -9,7 +9,7 @@ test('default fulfillment runs finance verification using its own departed trip const source = readFileSync(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url), 'utf8') assert.match(source, /import \{ verifyFleetTripFinance \} from '\.\/fleet-trip-finance-acceptance\.mjs'/) assert.match(source, /verifyFleetTripFinance\(context, MODULE, \{ tripNo: tripProof\.tripNo, dispatchNos: \[dispatchNo\]/) - assert.match(source, /businesses: deliveryNos\.map\(deliveryNo => \(\{ customerId, deliveryNo \}\)\)/) + assert.match(source, /businesses: deliveries\.map\(row => \(\{ customerId: id\(row\.customerId\), deliveryNo: row\.deliveryNo \}\)\)/) }) function fixture(status = 'AVAILABLE', received = null) { return { tripNo: scope.tripNo, dispatchNos: [...scope.dispatchNos], receiptsVisible: true, @@ -42,6 +42,19 @@ test('currencies are reconciled separately without floating point addition', () value.finance.totals[0].receivableAmount = '100.23' assert.throws(() => validateTripFinance(value, scope), /原币/) }) + +test('one dispatch retains each delivery customer in whole-trip finance attribution', () => { + const value = fixture() + const second = { customerId: '9007199254740994', deliveryNo: 'DL-SECOND' } + const mixed = { ...scope, businesses: [...scope.businesses, second] } + value.finance.sources.push({ ...value.finance.sources[0], customerId: second.customerId, + businessNo: second.deliveryNo, sourceNo: 'FS-SECOND', amount: '20.07' }) + value.finance.totals[0].receivableAmount = '120.10' + assert.equal(validateTripFinance(value, mixed), true) + assert.throws(() => validateTripFinance(value, scope), /其他客户或派送/) + value.finance.sources[1].customerId = scope.businesses[0].customerId + assert.throws(() => validateTripFinance(value, mixed), /其他客户或派送/) +}) test('pending allocation is explicitly unknown and never promoted to complete or zero', () => { const value = fixture('BILLED') value.finance.documents = [{ billNo: 'MIXED-BILL', currency: 'USD', receivedAmount: null, diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 667c33827..01a4a9a2b 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -2,272 +2,322 @@ import test from 'node:test' import assert from 'node:assert/strict' import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs' -const stops = [{ name: 'Origin' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] -const cargo = (customerId = 'A') => ({ dispatchNo: `DP-${customerId}`, customerId, warehousePalletId: '9007199254740993', palletNo: 'PLT-1', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }) +const D = '/api/platform/v1/last-leg/dispatches/DP-A' +const P = '/api/pda/v1/last-leg/dispatches/DP-A' +const F = '/api/platform/v1/last-leg/fleet/trips/TR-A' +const stops = [{ name: '真实起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] +const VID = '9007199254740993', DRIVER = '9007199254740995', PALLET = '9007199254740997' +const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) } }) +const conflict = message => ({ http: 200, body: { code: 'A0409', message } }) +const evidence = (field, value) => ({ field, value, actual: true, sourceType: 'TEST_PRODUCER_MEASUREMENT', sourceBusinessNo: 'RECEIPT-A', observedAt: '2026-10-18T10:00:00Z' }) +const pallet = () => ({ warehousePalletId: PALLET, palletNo: 'PLT-A', scheduledPackages: 3, pickedPackages: 3, + palletSpaces: null, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1, missingFields: ['palletSpaces'], + evidence: [evidence('weightKg', '187.25'), evidence('volumeCbm', '1.375')] }) +const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo: 'DP-A', pallets: [ + { warehousePalletId: PALLET, palletSpaces: 2, deliveryIndex: 1, evidence: [evidence('palletSpaces', 2)] }, +] }) -test('test loading measures physical pallet once across customers and rejects conflicting unloading points', () => { - assert.deepEqual(controlledPalletMeasurements([cargo(), cargo('B')], stops), [ - { warehousePalletId: '9007199254740993', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }, - ]) - assert.throws(() => controlledPalletMeasurements([{ ...cargo(), warehousePalletId: null }], stops), /缺少真实托盘/) - assert.throws(() => controlledPalletMeasurements([cargo()], [{ name: 'Origin' }]), /不在车次路线/) - assert.throws(() => controlledPalletMeasurements([cargo(), { ...cargo('B'), destinationKey: 'FBA:LAX9' }], [...stops, { destinationKey: 'FBA:LAX9' }]), /须先拆托/) -}) - -test('fixture prepares actual qualification upload and review before assignment and copies authoritative destination keys', async () => { - const calls = [] - let version = 0 - const context = { - marker: 'TEST-FLEET', createdResources: {}, - apiSuccess(_module, _label, response) { return response.body.data }, - async request(method, path, body) { - calls.push({ method, path, body }) - let data - if (path.endsWith('/master-data/vehicles')) { - assert.equal(body.status, 'IN_SERVICE', 'VehicleCreate requires an explicit status and trip assignment requires an in-service vehicle') - data = { id: 'vehicle-id' } - } - else if (path.endsWith('/drivers')) data = { id: 'driver-id', recordVersion: 0 } - else if (path.includes('/dispatches/') && path.endsWith('/review')) data = { trip: { recordVersion: version++ }, dispatches: [{ dispatchNo: 'DP-A', review: { status: 'APPROVED' } }] } - else if (path.endsWith('/review')) data = { reviewStatus: 'APPROVED' } - else if (path.endsWith('/loading')) data = { cargo: [cargo()] } - else { data = { trip: { tripNo: 'TR-FIXTURE', recordVersion: version++, stops: body.stops || stops } } } - return { http: 200, body: { code: '200', data } } - }, - async requestMultipart(method, path, file) { - calls.push({ method, path, file }) - assert.equal(file.type, 'image/png'); assert.ok(file.content.byteLength > 0) - return { http: 200, body: { code: '200', data: { id: 'driver-id', recordVersion: 1, licenseFileId: 'file-id' } } } - }, +test('real Jackson long counts accept safe decimal strings without changing pallet IDs', () => { + const expected = controlledPalletMeasurements([pallet()], stops, receipt().pallets) + for (const counts of [{ scheduledPackages: '3', pickedPackages: '3' }, { scheduledPackages: '3', pickedPackages: 3 }]) { + const actual = controlledPalletMeasurements([{ ...pallet(), ...counts }], stops, receipt().pallets) + assert.deepEqual(actual, expected) + assert.equal(actual[0].warehousePalletId, PALLET) } - const proof = await prepareFleetTrip(context, 'test', 'DP-A', '92') - assert.equal(proof.tripNo, 'TR-FIXTURE') - assert.equal(proof.reassignment.status, 'PENDING') - assert.ok(calls.findIndex(call => call.path.includes('/license?')) < calls.findIndex(call => call.path.endsWith('/review'))) - assert.ok(calls.findIndex(call => call.path.endsWith('/review')) < calls.findIndex(call => call.path.endsWith('/assignment'))) - assert.equal(calls.find(call => call.path.endsWith('/plan')).body.stops[1].destinationKey, 'FBA:ONT8') - assert.equal(context.createdResources.fleetTrips[0].licenseFileId, 'file-id') - assert.ok(calls.every(call => !call.path.endsWith('/depart'))) + for (const value of [null, undefined, '', '03', '3.0', '3e0', true, -1, '9007199254740993', 9007199254740992]) { + assert.throws(() => controlledPalletMeasurements([{ ...pallet(), scheduledPackages: value }], stops, receipt().pallets), /箱数/) + assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: value }], stops, receipt().pallets), /箱数/) + } + assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: '2' }], stops, receipt().pallets), /核平/) }) -test('unready actual dispatch prevents load confirmation rather than bypassing it with test values', async () => { - const calls = [] - const context = { request: async (method, path) => { calls.push({ method, path }); return { http: 200, body: { code: '200', data: { blockReason: '拣货未完成' } } } }, - apiSuccess(_module, _name, response) { return response.body.data } } - await assert.rejects(confirmFleetLoading(context, 'test', { tripNo: 'TR-1', stops }), /整车未就绪/) - assert.equal(calls.length, 1) - assert.equal(calls[0].method, 'GET') -}) - -function reassignmentServer(fault = '') { - const proof = { tripNo: 'TR-FRESH', dispatchNo: 'DP-A', vehicleId: '101', driverId: '201', stops, - reassignment: { status: 'PENDING' } } - const trip = { tripNo: proof.tripNo, recordVersion: 4, vehicleId: '101', driverId: '201', vehiclePlate: 'ORIGINAL', - driverName: 'Original driver', driverPhone: '123', status: 'PLANNED', stops, maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } - const dispatch = { dispatchNo: proof.dispatchNo, overseasWarehouseId: '92', status: 'READY_TO_DEPART', - packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, remainingPackageCount: 0, - estimatedArrivalAt: '2026-10-03T12:00:00', items: [{ id: '9007199254740995', inboundCode: 'IN-TEST', packageCount: 10 }], - vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone } - const calls = [], assertions = [], dispatches = [{ dispatchNo: proof.dispatchNo, customerId: 'A', review: { status: 'APPROVED' } }] - let load = null, currentCargo = [cargo()], assigned = false - const ok = data => ({ http: 200, body: { code: '200', data: fault === 'string-versions' - ? JSON.parse(JSON.stringify(data, (key, value) => key === 'recordVersion' ? String(value) : value)) : structuredClone(data) } }) - if (fault === 'string-versions') trip.recordVersion = 8 - const loading = () => ({ recordVersion: trip.recordVersion, manifestHash: 'a'.repeat(64), cargo: currentCargo, confirmedLoad: load, blockReason: null }) +function server(fault = '') { + const calls = [], assertions = [], saved = new Map() + const detail = { dispatchNo: 'DP-A', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: '9007199254740998', + counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, + dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} } + const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops } + let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail + const claims = new Map([['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A']]) + const bump = () => detail.recordVersion = String(BigInt(detail.recordVersion) + 1n) + const result = (action, body, extra = {}) => ({ dispatchNo: 'DP-A', status: detail.status, stage: detail.stage, recordVersion: detail.recordVersion, + action, idempotencyKey: body.idempotencyKey, result: extra, replayed: false, counts: structuredClone(detail.counts) }) + const remember = (action, body, extra) => { const r = result(action, body, extra); saved.set(body.idempotencyKey, { body: structuredClone(body), result: structuredClone(r) }); return ok(r) } const context = { - marker: 'TEST-REASSIGN', + marker: 'TEST-NATIVE', createdResources: {}, + checkpoint: async () => { if (checkpointError) throw new Error('checkpoint failed') }, assert(_module, label, _response, passed) { assertions.push({ label, passed }) }, - apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null }, + apiSuccess(_module, _label, response) { return response?.body?.code === '200' ? response.body.data : null }, async requestMultipart(method, path, file) { - calls.push({ method, path }); assert.equal(file.type, 'image/png') - return ok({ licenseFileId: 'FILE-NEW', recordVersion: 1 }) + calls.push({ method, path, file: { name: file.name, size: file.content.byteLength } }) + assert.equal(file.type, 'image/png'); assert.match(file.name, /TEST-LICENSE/) + uploadComplete = true; driverVersion++ + return ok({ id: DRIVER, licenseFileId: '9007199254740999', recordVersion: driverVersion }) }, - async request(method, path, body) { - calls.push({ method, path, body: structuredClone(body) }) - if (path.endsWith('/loading')) { - if (method === 'GET') return ok(loading()) - if (String(body.recordVersion) !== String(trip.recordVersion) && fault !== 'accept-old-version') { - if (fault === 'reject-but-write') load = { manifestHash: body.manifestHash, pallets: body.pallets } - return { http: 200, body: { code: 'A0409', message: fault === 'unrelated-conflict' ? '权限未配置' : '车次已被修改,请刷新后重试' } } + async request(method, path, body, token) { + calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' }) + if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown') + if (path.endsWith('/master-data/vehicles')) { + assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber + return ok({ id: VID }) + } + if (path.endsWith('/fleet/drivers')) return ok({ id: DRIVER, recordVersion: 0 }) + if (path.endsWith(`/drivers/${DRIVER}/review`)) { + assert.ok(uploadComplete); assert.equal(body.recordVersion, driverVersion); driverReviewed = true + return ok({ id: DRIVER, reviewStatus: 'APPROVED', recordVersion: ++driverVersion }) + } + if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) { + const rows = fault === 'missing-trip' ? [] : [trip, ...(nextTrip ? [nextTrip] : [])] + if (fault === 'duplicate-trip') rows.push({ ...trip, tripNo: 'TR-B' }) + return ok({ records: rows, page: 1, size: 200, total: rows.length, pages: 1 }) + } + if (method === 'GET' && path === D) return ok(detail) + if (method === 'GET' && path === `${D.replace('DP-A', 'DP-NEXT')}`) return ok(nextDetail) + if (method === 'GET' && path === `${D.replace('DP-A', 'DP-NEXT')}/items`) return ok({ dispatchNo: 'DP-NEXT', recordVersion: nextDetail.recordVersion, items: [] }) + if (method === 'POST' && path === D.replace('/DP-A', '')) { + assert.deepEqual(body.cargo, []); assert.deepEqual(body.batchAllocations, []) + assert.equal(body.vehicle.vehicleId, VID); assert.equal(body.driver.driverId, DRIVER) + assert.equal(claims.size, 0) + if (fault === 'unknown-next-create') throw new Error('next create unknown') + nextTrip = { ...trip, tripNo: 'TR-NEXT', status: 'PLANNED', recordVersion: 0, vehicleRelease: null, driverRelease: null } + nextDetail = { dispatchNo: 'DP-NEXT', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, + counts: { itemCount: 0, palletCount: 0, packageCount: 0, pickedPackageCount: 0, shippedPackageCount: 0 } } + if (fault === 'new-empty-has-cargo') nextDetail.counts.itemCount = 1 + claims.set('VEHICLE', 'TR-NEXT'); claims.set('DRIVER', 'TR-NEXT') + return ok({ ...nextDetail, result: { dispatchNo: 'DP-NEXT' } }) + } + if (method === 'POST' && path === `${D.replace('DP-A', 'DP-NEXT')}/cancel`) { + assert.equal(body.recordVersion, nextDetail.recordVersion) + nextDetail.status = 'CANCELLED'; nextDetail.recordVersion++; claims.clear() + return ok(nextDetail) + } + if (method === 'GET' && path === `${D}/items`) return ok({ dispatchNo: 'DP-A', recordVersion: detail.recordVersion, items: [{ warehousePalletId: PALLET, pickedPackages: detail.counts.pickedPackageCount, customerId: 'CUSTOMER-A' }] }) + if (method === 'GET' && [D, P].some(prefix => path === `${prefix}/loading`)) { + return ok({ dispatchNo: 'DP-A', status: detail.status, stage: detail.stage, recordVersion: detail.recordVersion, pickingReconciled: detail.counts.pickedPackageCount === 3, + manifestHash: 'REAL-MANIFEST', loadingComplete: { allowed: detail.counts.pickedPackageCount === 3, blockReason: '拣货未核平' }, + pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] }) + } + if (method === 'PUT' && path === D) { + if (detail.stage !== 'BEFORE_LOADING') { + if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount-- + return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派') } - load = { manifestHash: body.manifestHash, pallets: body.pallets, cargo: currentCargo } - if (assigned && fault === 'wrong-final-measurement') load.pallets = [{ ...body.pallets[0], weightKg: 200 }] - trip.recordVersion++; return ok(loading()) + assert.ok(driverReviewed); assert.equal(body.recordVersion, detail.recordVersion) + assert.equal(body.vehicle.mode, 'MASTER'); assert.equal(body.vehicle.vehicleId, VID) + assert.equal(body.driver.driverId, DRIVER); assert.ok(body.driver.evidence.expectedTaskEndAt) + detail.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: VID, plate: nextPlate, carrierName: 'TEST自有车', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } }, + driver: { mode: 'MASTER', driverId: DRIVER, name: 'TEST司机', phone: '19900000000' } } + Object.assign(trip, { vehicleId: VID, vehiclePlate: nextPlate, driverId: DRIVER }) + bump(); return remember('EDIT', body) } - if (path.endsWith('/master-data/vehicles')) { - assert.equal(body.status, 'IN_SERVICE'); assert.equal(body.maxLoadKg, trip.maxLoadKg) - return ok({ id: '102' }) + if (method === 'POST' && path === `${D}/return-to-review`) { + assert.equal(detail.counts.pickedPackageCount, 0); assert.equal(body.recordVersion, detail.recordVersion) + detail.stage = 'BEFORE_LOADING'; detail.reviewState = 'DRAFT'; bump(); return remember('RETURN_TO_REVIEW', body) } - if (path.endsWith('/drivers')) return ok({ id: '202', recordVersion: 0 }) - if (path.includes('/dispatches/') && path.endsWith('/review')) { - assert.equal(String(body.recordVersion), String(trip.recordVersion)); trip.recordVersion++ - dispatches[0].review = { status: 'APPROVED' }; return ok({ trip, dispatches }) + if (method === 'POST' && ['submit-review', 'dispatch-review', 'loading-review'].some(action => path === `${D}/${action}`)) { + assert.equal(body.recordVersion, detail.recordVersion); assert.match(body.idempotencyKey, /^[a-f0-9]{64}$/) + if (path.endsWith('/submit-review')) { detail.reviewState = 'DISPATCH_PENDING'; detail.reviews.dispatchReview = { status: 'PENDING' } } + else if (path.endsWith('/dispatch-review')) { assert.equal(detail.reviewState, 'DISPATCH_PENDING'); detail.reviewState = 'LOADING_PENDING'; detail.reviews.dispatchReview.status = 'APPROVED' } + else { assert.equal(detail.reviewState, 'LOADING_PENDING'); detail.reviewState = 'APPROVED'; detail.stage = 'LOADING'; detail.reviews.loadingReview = { status: 'APPROVED' } } + bump(); return remember('REVIEW', body) } - if (path.endsWith('/loading-review')) { - assert.equal(String(body.recordVersion), String(trip.recordVersion)); assert.ok(load); trip.recordVersion++ - return ok({ trip, dispatches, loadingReview: { status: 'APPROVED' } }) + if (method === 'POST' && [D, P].some(prefix => path === `${prefix}/loading-complete`)) { + if (saved.has(body.idempotencyKey)) { + assert.deepEqual(body, saved.get(body.idempotencyKey).body) + const replay = { ...saved.get(body.idempotencyKey).result, replayed: true } + if (fault === 'replay-new-version') replay.recordVersion = String(BigInt(replay.recordVersion) + 1n) + return ok(replay) + } + assert.equal(detail.stage, 'LOADING'); assert.equal(detail.counts.pickedPackageCount, 3) + assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST') + assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }]) + detail.stage = 'AFTER_LOADING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } }) } - if (path.endsWith('/review')) return ok({ reviewStatus: 'APPROVED' }) - if (path.endsWith('/assignment')) { - assert.equal(String(body.recordVersion), String(trip.recordVersion)) - Object.assign(trip, { vehicleId: body.vehicleId, driverId: body.driverId, vehiclePlate: 'REPLACEMENT', driverName: 'Replacement driver', driverPhone: '456' }) - trip.recordVersion++; assigned = true; dispatches[0].review = { status: 'PENDING' } - if (fault !== 'keeps-old-load') load = null - Object.assign(dispatch, { vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone }) - if (fault === 'changes-picking') dispatch.pickedPackageCount = 0 - if (fault === 'changes-customer') currentCargo = [{ ...cargo(), customerId: 'B' }] - if (fault === 'unknown-assignment-outcome') throw new Error('connection interrupted after commit') - return ok({ trip, dispatches }) + if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) { + const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind') + return ok([{ resourceId: kind === 'VEHICLE' ? VID : DRIVER, occupiedTripNo: claims.get(kind) ?? null, available: true }]) } - if (path.endsWith(`/dispatches/${proof.dispatchNo}`)) return ok(dispatch) - if (path.endsWith(`/trips/${proof.tripNo}`)) return ok({ trip, dispatches }) - throw new Error(`unexpected request ${method} ${path}`) - }, - } - return { proof, calls, assertions, context, trip } -} - -test('fresh-run load checks reassignment, unchanged work, stale version rejection and fresh confirmation before returning departure version', async () => { - const server = reassignmentServer() - const version = await confirmFleetLoading(server.context, 'test', server.proof) - assert.equal(server.proof.reassignment.status, 'VERIFIED') - assert.equal(version, 9) - assert.equal(server.proof.vehicleId, '102'); assert.equal(server.proof.driverId, '202') - assert.equal(server.proof.reassignment.originalTrip.vehicleId, '101') - assert.equal(server.proof.reassignment.dispatchAfter.pickedPackageCount, 10) - assert.equal(server.proof.confirmedLoad.pallets[0].weightKg, 100) - assert.ok(server.assertions.every(row => row.passed)) - assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).length, 3) - assert.ok(server.calls.every(row => !/\/depart|\/pick|\/complete$/.test(row.path)), 'reassignment does not repeat physical warehouse work') -}) - -test('string-serialized versions compare numerically across 9 to 10 and remain unchanged in requests', async () => { - const server = reassignmentServer('string-versions') - assert.equal(await confirmFleetLoading(server.context, 'test', server.proof), '13') - assert.equal(server.proof.reassignment.status, 'VERIFIED') - assert.equal(server.proof.reassignment.assignmentRequest.recordVersion, '9') - assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).at(-1).body.recordVersion, '11') -}) - -for (const [fault, message] of [ - ['keeps-old-load', /原装载失效/], ['changes-picking', /保留拣货/], ['changes-customer', /客户归属/], - ['accept-old-version', /旧版本不能/], ['unrelated-conflict', /旧版本不能/], - ['reject-but-write', /拒绝后仍未确认/], ['wrong-final-measurement', /装载确认返回/], - ['unknown-assignment-outcome', /connection interrupted/], -]) test(`reassignment stops on ${fault} and retains requests without silently retrying`, async () => { - const server = reassignmentServer(fault) - await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), message) - assert.equal(server.proof.reassignment.status, 'CHECKING') - assert.equal(server.proof.reassignment.assignmentRequest.vehicleId, '102') - const callCount = server.calls.length - await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), /已开始/) - assert.equal(server.calls.length, callCount) -}) - -test('retained dispatch loading does not create replacement resources or mutate assignment', async () => { - const server = reassignmentServer() - delete server.proof.reassignment - const version = await confirmFleetLoading(server.context, 'test', server.proof) - assert.equal(version, 6) - assert.equal(server.calls.length, 3) - assert.ok(server.calls.every(row => (row.path.endsWith('/loading') || row.path.endsWith('/loading-review')))) -}) - -function closureServer(fault = '') { - const proof = { tripNo: 'TR-ORIGINAL', vehicleId: '9007199254740993', driverId: '9007199254740995', warehouseId: '92', stops } - const trips = new Map([[proof.tripNo, { tripNo: proof.tripNo, recordVersion: 5, vehicleId: proof.vehicleId, driverId: proof.driverId, - status: 'IN_TRANSIT', completedAt: null, vehicleRelease: null, driverRelease: null }]]) - const claims = new Map([[`VEHICLE:${proof.vehicleId}`, proof.tripNo], [`DRIVER:${proof.driverId}`, proof.tripNo]]) - const remembered = new Map(), calls = [], assertions = [] - let sequence = 0 - const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) } }) - const fail = message => ({ http: 200, body: { code: 'A0409', message } }) - const context = { - marker: 'CONTROLLED-TEST', - assert(_module, label, _response, passed) { assertions.push({ label, passed }) }, - apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null }, - async request(method, path, body) { - calls.push({ method, path, body: structuredClone(body) }) - if (path.endsWith('/master-data/vehicles')) { - assert.equal(body.status, 'IN_SERVICE', 'the independent driver occupancy probe needs a valid in-service vehicle') - return ok({ id: 'SPARE-VEHICLE' }) - } - if (method === 'POST' && path.endsWith('/fleet/trips')) { - const tripNo = `TR-PROBE-${++sequence}` - const trip = { tripNo, recordVersion: 0, status: 'PLANNED', vehicleId: null, driverId: null } - trips.set(tripNo, trip); return ok({ trip }) - } - const match = path.match(/\/trips\/([^/]+)(.*)$/), trip = trips.get(match?.[1]), suffix = match?.[2] - assert.ok(trip, `unexpected request ${path}`) - if (method === 'GET' && suffix === '/completion') return ok({ recordVersion: trip.recordVersion, canComplete: true, outcomes: [cargo()] }) - if (method === 'GET' && !suffix) return ok({ trip }) - if (suffix === '/complete' || suffix === '/resources/release') { - const key = `${trip.tripNo}:${body.idempotencyKey}` - if (remembered.has(key)) { - assert.deepEqual(body, remembered.get(key), 'retry must preserve exact original body') - if (fault === 'old-release-clears-new-claim' && suffix === '/resources/release') claims.delete(`${body.resourceKind}:${body.resourceKind === 'VEHICLE' ? trip.vehicleId : trip.driverId}`) + if (method === 'GET' && path === `${F}/completion`) return ok({ tripNo: 'TR-A', recordVersion: trip.recordVersion, canComplete: fault !== 'incomplete', outcomes: [{ deliveryNo: 'DL-A', packages: 3 }] }) + if (method === 'GET' && path === F) return ok({ trip, dispatches: fault === 'wrong-binding' ? [{ dispatchNo: 'DP-OTHER' }] : [{ dispatchNo: 'DP-A' }] }) + if (method === 'GET' && path === F.replace('TR-A', 'TR-B')) return ok({ trip: { ...trip, tripNo: 'TR-B' }, dispatches: [{ dispatchNo: 'DP-A' }] }) + if (method === 'GET' && path === F.replace('TR-A', 'TR-NEXT')) return ok({ trip: nextTrip, dispatches: [{ dispatchNo: 'DP-NEXT' }] }) + if (method === 'POST' && [ `${F}/complete`, `${F}/resources/release` ].includes(path)) { + if (saved.has(body.idempotencyKey)) { + assert.deepEqual(body, saved.get(body.idempotencyKey)) + if (fault === 'old-release-clears-new-claim' && nextTrip && path.endsWith('/resources/release')) claims.delete(body.resourceKind) return ok({ trip }) } - assert.equal(body.recordVersion, trip.recordVersion) - remembered.set(key, structuredClone(body)); trip.recordVersion++ - if (suffix === '/complete') { trip.status = 'COMPLETED'; trip.completedAt = '2026-10-02T00:00:00Z' } - else { - const vehicle = body.resourceKind === 'VEHICLE' - claims.delete(`${body.resourceKind}:${vehicle ? trip.vehicleId : trip.driverId}`) - trip[vehicle ? 'vehicleRelease' : 'driverRelease'] = { actualEndAt: body.actualEndAt, endLocation: body.endLocation, vehicleStatus: body.vehicleStatus } - if (fault === 'vehicle-also-frees-driver' && vehicle) claims.delete(`DRIVER:${trip.driverId}`) + assert.equal(body.recordVersion, trip.recordVersion); saved.set(body.idempotencyKey, structuredClone(body)); trip.recordVersion++ + if (path.endsWith('/complete')) { + trip.status = 'COMPLETED'; trip.completedAt = '2026-10-19T00:00:00Z' + if (fault === 'completion-auto-releases') { claims.clear(); trip.vehicleRelease = { actualEndAt: trip.completedAt } } + } else { + claims.delete(body.resourceKind) + trip[body.resourceKind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease'] = { actualEndAt: body.actualEndAt, endLocation: body.endLocation } + if (fault === 'vehicle-frees-driver' && body.resourceKind === 'VEHICLE') claims.delete('DRIVER') } return ok({ trip }) } - if (suffix === '/assignment') { - if (fault === 'unrelated-conflict') return fail('车次版本已变化') - if (claims.has(`VEHICLE:${body.vehicleId}`)) return fail('车辆已被其他车次占用') - if (claims.has(`DRIVER:${body.driverId}`)) return fail('司机已被其他车次占用') - assert.equal(body.recordVersion, trip.recordVersion) - trip.vehicleId = body.vehicleId; trip.driverId = body.driverId; trip.recordVersion++ - claims.set(`VEHICLE:${body.vehicleId}`, trip.tripNo); claims.set(`DRIVER:${body.driverId}`, trip.tripNo) - return ok({ trip }) - } - if (suffix === '/cancel') { - assert.equal(body.recordVersion, trip.recordVersion) - for (const [key, owner] of claims) if (owner === trip.tripNo) claims.delete(key) - trip.status = 'CANCELLED'; trip.recordVersion++; return ok({ trip }) - } - throw new Error(`unhandled request ${method} ${path}`) + assert.fail(`retired or unexpected request ${method} ${path}`) }, } - return { proof, trips, claims, calls, assertions, context } + return { context, detail, trip, calls, assertions, claims, pick() { detail.counts.pickedPackageCount = 3; bump() }, failCheckpoint() { checkpointError = true } } } -test('closure proves independent resource occupancy through real command-shaped probes, then cancels empty plans', async () => { - const server = closureServer() - const result = await finishFleetTrip(server.context, 'test', server.proof) - assert.equal(result.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') - assert.equal(server.claims.size, 0) - assert.ok(server.assertions.every(row => row.passed)) - assert.equal(result.probes.length, 2) - assert.ok(result.probes.every(row => row.finalStatus === 'CANCELLED')) - assert.equal(server.calls.filter(row => row.path.endsWith('/resources/release')).length, 4) - assert.equal(server.calls.filter(row => row.path.endsWith('/assignment')).length, 5) - assert.ok(server.calls.every(row => !/\/depart|\/items\/.*\/pick/.test(row.path)), 'closure never replays physical departure or inventory work') - await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /已开始/) +const prepare = s => prepareFleetTrip(s.context, 'native', 'DP-A', '92') + +function jacksonCountWire(s) { + const names = new Set(['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'scheduledPackages', 'pickedPackages']) + const serialize = value => Array.isArray(value) ? value.map(serialize) + : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([name, nested]) => + [name, names.has(name) && typeof nested === 'number' ? String(nested) : serialize(nested)])) : value + const original = s.context.request + s.context.request = async (...args) => serialize(await original(...args)) + return s +} + +test('real string-zero counts permit preparation, PDA loading replay and safe empty-dispatch closure', async () => { + const s = jacksonCountWire(server()), proof = await prepare(s) + assert.equal(proof.preparation, 'VERIFIED') + s.pick() + await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt()) + s.trip.status = 'IN_TRANSIT' + const closure = await finishFleetTrip(s.context, 'native', proof) + assert.equal(closure.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED') + assert.ok(s.assertions.every(row => row.passed)) }) -test('closure fails if ending only the vehicle silently releases the driver too', async () => { - const server = closureServer('vehicle-also-frees-driver') - await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /司机仍不能/) - assert.equal(server.proof.closure.status, 'CHECKING') - assert.ok(server.assertions.some(row => row.passed === false)) +test('missing or malformed zero counts cannot create resources before preparation', async () => { + for (const value of [undefined, null, false, '', '00', '0.0', '0e0', '9007199254740993']) { + const s = server(); s.detail.counts.pickedPackageCount = value + await assert.rejects(prepare(s), /箱数/) + assert.deepEqual(s.calls.map(row => row.method), ['GET']) + } }) -test('closure detects an old release command deleting a subsequent trip claim', async () => { - const server = closureServer('old-release-clears-new-claim') - await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /旧结束重放不能释放/) - assert.notEqual(server.proof.closure.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') +test('native preparation edits existing master then submits both reviews, discovers the real sole automatic Fleet record', async () => { + const s = server(), proof = await prepare(s) + assert.equal(proof.tripNo, 'TR-A'); assert.equal(proof.preparation, 'VERIFIED'); assert.equal(proof.vehicleId, VID) + assert.equal(proof.vehicle.plate, proof.marker); assert.equal(proof.driver.phone, '19900000000') + assert.equal(proof.recordVersion, '9007199254741002'); assert.equal(proof.fleetRecordVersion, 4) + const writes = s.calls.filter(c => ['POST', 'PUT'].includes(c.method)) + assert.deepEqual(writes.slice(-4).map(c => c.path), [D, `${D}/submit-review`, `${D}/dispatch-review`, `${D}/loading-review`]) + assert.ok(writes.every(c => !/\/trips(?:$|\/.*\/(?:assignment|plan|bind|review))/.test(c.path))) + assert.equal(s.detail.dispatchInfo.fbaAppointments[0].appointmentNo, 'FBA-ORIGINAL') + assert.ok(s.assertions.every(a => a.passed)) }) -test('an unrelated conflict cannot stand in for proof of occupied resources', async () => { - const server = closureServer('unrelated-conflict') - await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /未证明资源占用/) - assert.equal(server.calls.some(row => row.path.endsWith('/resources/release')), false) +test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => { + const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED' + const proof = await prepare(s) + const returnIndex = s.calls.findIndex(c => c.path.endsWith('/return-to-review')) + assert.ok(returnIndex >= 0 && returnIndex < s.calls.findIndex(c => c.method === 'PUT')) + assert.equal(proof.returnRequest.recordVersion, '9007199254740998'); assert.equal(proof.preparation, 'VERIFIED') +}) + +for (const fault of ['missing-trip', 'duplicate-trip', 'wrong-binding']) test(`automatic Fleet identity fails closed: ${fault}`, async () => { + const s = server(fault); await assert.rejects(prepare(s), /唯一|绑定/) + assert.equal(s.context.createdResources.fleetTrips[0].preparation, 'CHECKING') +}) + +test('started picking cannot be silently prepared, reassigned or returned', async () => { + const s = server(); s.detail.stage = 'LOADING'; s.detail.counts.pickedPackageCount = 1 + await assert.rejects(prepare(s), /零扫码/) + assert.equal(s.calls.length, 1) +}) + +test('unknown write result retains the exact command, does not retry or generate another Fleet record', async () => { + const s = server('unknown-edit'); await assert.rejects(prepare(s), /unknown/) + const last = s.context.createdResources.fleetTripCommands.at(-1) + assert.equal(last.status, 'UNKNOWN'); assert.equal(last.path, D) + assert.equal(s.calls.filter(c => c.method === 'PUT').length, 1) +}) + +test('checkpoint failure sends no business write', async () => { + const s = server(); s.failCheckpoint(); await assert.rejects(prepare(s), /checkpoint/) + assert.deepEqual(s.calls.map(c => c.method), ['GET']) +}) + +test('actual loading uses authoritative decimal measurements and a proven producer pallet-space measurement', () => { + assert.deepEqual(controlledPalletMeasurements([pallet()], stops, receipt().pallets), [ + { warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }, + ]) +}) + +for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) test(`missing actual ${field} blocks; billing values cannot substitute`, () => { + const row = pallet(), r = receipt(); row[field] = null; delete r.pallets[0][field] + row.grossWeightKg = 999; row.billingWeight = 999 + assert.throws(() => controlledPalletMeasurements([row], stops, r.pallets), /实际测量缺少/) +}) + +test('suggested measurements, duplicate physical pallets and unpicked cargo are rejected', () => { + const row = pallet(); row.evidence[0].actual = false + assert.throws(() => controlledPalletMeasurements([row], stops, receipt().pallets), /真实来源/) + assert.throws(() => controlledPalletMeasurements([pallet(), pallet()], stops, receipt().pallets), /重复/) + assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: 2 }], stops, receipt().pallets), /核平/) + assert.throws(() => controlledPalletMeasurements([pallet()], [{ name: 'Origin' }], receipt().pallets), /卸货站/) +}) + +test('extra or inconsistent producer measurements cannot alter another pallet or authoritative actual weight', () => { + const r = receipt(); r.pallets[0].weightKg = 100 + assert.throws(() => controlledPalletMeasurements([pallet()], stops, r.pallets), /不一致/) + assert.throws(() => controlledPalletMeasurements([pallet()], stops, [...receipt().pallets, { warehousePalletId: 'OTHER' }]), /本车以外/) +}) + +test('PDA actual loading and platform exact replay share one native version after proving scanned edit refusal', async () => { + const s = server(), proof = await prepare(s); s.pick() + const beforeCount = s.detail.counts.pickedPackageCount + const v = await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt()) + assert.equal(v, '9007199254741004'); assert.equal(proof.reassignment.status, 'REJECTED_WITHOUT_MUTATION') + assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'AFTER_LOADING') + const writes = s.calls.filter(c => c.path.endsWith('/loading-complete')) + assert.equal(writes.length, 2); assert.deepEqual(writes[0].body, writes[1].body) + assert.equal(writes[0].actor, 'PDA'); assert.equal(writes[1].actor, 'PLATFORM') + assert.equal(writes[0].body.measurements[0].weightKg, '187.25') + assert.ok(s.calls.every(c => !/\/fleet\/trips\/.*\/(?:assignment|loading-review|loading)$/.test(c.path))) + const count = s.calls.length + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/) + assert.equal(s.calls.length, count) +}) + +for (const fault of ['edit-mutates-on-rejection', 'replay-new-version']) test(`loading verification rejects native drift: ${fault}`, async () => { + const s = server(fault), proof = await prepare(s); s.pick() + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /失败/) + if (fault === 'edit-mutates-on-rejection') assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false) +}) + +test('missing actual producer measurement blocks before any edit rejection or loading write', async () => { + const s = server(), proof = await prepare(s); s.pick(); const count = s.calls.length + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA'), /palletSpaces/) + assert.equal(s.calls.length, count + 1); assert.equal(s.calls.at(-1).method, 'GET') +}) + +test('wrong-run producer receipt and unreconciled picking block loading without mutation', async () => { + const s = server(), proof = await prepare(s) + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /未就绪/) + s.pick(); await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', { ...receipt(), marker: 'OTHER-RUN' }), /不属于本轮/) + assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false) +}) + +test('closure keeps cargo completion, vehicle ending and driver ending distinct using real occupancy owners', async () => { + const s = server(), proof = await prepare(s); s.trip.status = 'IN_TRANSIT' + const state = await finishFleetTrip(s.context, 'native', proof) + assert.equal(state.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'); assert.equal(s.claims.size, 0) + assert.equal(state.subsequentTripClaimProtection, 'VERIFIED_NATIVE_EMPTY_DISPATCH') + assert.equal(state.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(state.nextDispatch.finalStatus, 'CANCELLED') + assert.deepEqual(state.occupancy.map(c => [c.kind, c.rows[0].occupiedTripNo]), [ + ['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A'], ['VEHICLE', null], ['DRIVER', 'TR-A'], ['VEHICLE', null], ['DRIVER', null], + ['VEHICLE', 'TR-NEXT'], ['DRIVER', 'TR-NEXT'], ['VEHICLE', 'TR-NEXT'], ['DRIVER', 'TR-NEXT'], ['VEHICLE', null], ['DRIVER', null], + ]) + assert.equal(s.calls.filter(c => c.path.endsWith('/resources/release')).length, 4) + assert.ok(s.calls.every(c => !(c.method === 'POST' && /\/fleet\/trips$|\/assignment$/.test(c.path)))) + await assert.rejects(finishFleetTrip(s.context, 'native', proof), /已开始/) +}) + +for (const fault of ['incomplete', 'completion-auto-releases', 'vehicle-frees-driver', 'old-release-clears-new-claim', 'new-empty-has-cargo', 'unknown-next-create']) test(`closure does not claim success on independent-resource failure: ${fault}`, async () => { + const s = server(fault), proof = await prepare(s); s.trip.status = 'IN_TRANSIT' + await assert.rejects(finishFleetTrip(s.context, 'native', proof), /失败|unknown/) + assert.equal(proof.closure.status, 'CHECKING') + if (['new-empty-has-cargo', 'unknown-next-create'].includes(fault)) assert.equal(s.calls.some(c => c.path.endsWith('/DP-NEXT/cancel')), false) }) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index 9bfd17468..ba9e8b1df 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -1,296 +1,316 @@ import { createHash, randomUUID } from 'node:crypto' const BASE = '/api/platform/v1/last-leg/fleet' +const DISPATCH = '/api/platform/v1/last-leg/dispatches' +const PDA = '/api/pda/v1/last-leg/dispatches' const OPTIONS = { scenario: 'fleet-physical-trip-departure', deploymentBlocker: true } -const id = value => value == null ? '' : String(value) +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('服务端资源ID超出安全整数范围,必须保留原始字符串') + return value == null ? '' : String(value) +} +const key = (proof, action) => createHash('sha256').update(`${proof.marker}:${proof.dispatchNo}:${action}`).digest('hex') const version = value => { - if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) - throw new Error('服务端车次版本无效') + if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw new Error('服务端排车版本无效') return BigInt(String(value)) } - -async function command(context, module, method, path, body, label) { - const response = await context.request(method, path, body) +function check(context, module, label, condition, evidence = '') { + context.assert(module, label, null, condition, evidence, OPTIONS) + if (!condition) throw new Error(`${module}: ${label}失败`) +} +// 写前冻结原请求;未知结果停写,不能换键重造或自动重发。 +async function command(context, module, method, path, body, label, token) { + let entry + if (method !== 'GET') { + context.createdResources ??= {}; context.createdResources.fleetTripCommands ??= [] + entry = { method, path, body: structuredClone(body), status: 'PREPARED' } + context.createdResources.fleetTripCommands.push(entry); await context.checkpoint?.() + } + let response + try { response = await context.request(method, path, body, token) } + catch (error) { if (entry) { entry.status = 'UNKNOWN'; await context.checkpoint?.() }; throw error } + if (entry) { + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId }) + await context.checkpoint?.() + } const data = context.apiSuccess(module, label, response, '', OPTIONS) if (response?.http !== 200 || String(response?.body?.code) !== '200' || !data) throw new Error(`${module}: ${label}失败`) return data } -/** Test-only resources are created through real APIs and retained in the run report. No business SQL. */ +// 排车公开详情未提供tripNo,按本轮唯一新车牌分页,再核对真实资源及唯一绑定。 +async function discoverTrip(context, module, proof) { + const rows = [], seen = new Set(); let total + for (let page = 1; ; page++) { + const query = new URLSearchParams({ page: String(page), size: '200', keyword: proof.marker, warehouseId: proof.warehouseId }) + const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按本轮唯一车牌读取自动资源记录') + if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 + || Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联') + total ??= Number(data.total) + if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && rows.length < total)) throw new Error('车次分页变化或缺页') + for (const row of data.records) { + if (!row.tripNo || seen.has(row.tripNo)) throw new Error('车次分页业务号缺失或重复') + seen.add(row.tripNo); rows.push(row) + } + if (rows.length >= total) break + } + if (rows.length !== total) throw new Error('车次分页总数不一致') + const matches = rows.filter(row => row.vehiclePlate === proof.marker && id(row.vehicleId) === proof.vehicleId + && id(row.driverId) === proof.driverId && id(row.warehouseId) === proof.warehouseId && row.tripPurpose === 'DELIVERY') + const bound = [] + for (const candidate of matches) { + const detail = await command(context, module, 'GET', `${BASE}/trips/${encodeURIComponent(candidate.tripNo)}`, undefined, '核对排车与资源记录唯一绑定') + if (detail.trip?.tripNo !== candidate.tripNo || id(detail.trip.vehicleId) !== proof.vehicleId || id(detail.trip.driverId) !== proof.driverId) + throw new Error('真实车次候选资源发生变化') + if (detail.dispatches?.some(row => row.dispatchNo === proof.dispatchNo)) { + if (detail.dispatches.length !== 1) throw new Error('原生排车资源记录仍关联多张子单') + bound.push(detail) + } + } + if (bound.length !== 1) throw new Error(`本轮排车自动资源记录不是唯一绑定:${bound.length}`) + const [detail] = bound + Object.assign(proof, { tripNo: detail.trip.tripNo, trip: detail.trip, stops: detail.trip.stops, fleetRecordVersion: detail.trip.recordVersion }) +} + +/** 本轮TEST主档经正式API建立;主单创建时已自动建立Fleet资源记录。 */ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) { if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓') - const marker = `FT-${randomUUID().slice(0, 12)}` - const proof = { dispatchNo, warehouseId: id(warehouseId), marker, reassignment: { status: 'PENDING' } } - context.createdResources.fleetTrips ??= [] - context.createdResources.fleetTrips.push(proof) - const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { - plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100, - region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, - remark: `${context.marker} controlled TEST vehicle`, - }, '建立本轮真实车辆档案及容量') - proof.vehicleId = id(vehicle.id) - await prepareQualifiedDriver(context, module, marker, proof) - const plannedDepartureAt = new Date(Date.now() + 3600000).toISOString() - const expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString() - const createKey = createHash('sha256').update(`${context.marker}:${dispatchNo}`).digest('hex') - let detail = await command(context, module, 'POST', `${BASE}/trips`, { - warehouseId, plannedDepartureAt, expectedDriverEndAt, idempotencyKey: createKey, - stops: [{ name: '测试起运仓' }, { name: '待匹配货物目的地' }], remark: context.marker, - }, '创建本轮共享运输车次') - proof.tripNo = detail.trip.tripNo - const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` - detail = await command(context, module, 'POST', `${path}/assignment`, { - recordVersion: detail.trip.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '本轮测试派车', - }, '正式分配车辆司机并核验证照') - detail = await command(context, module, 'POST', `${path}/dispatches/bind`, { - recordVersion: detail.trip.recordVersion, dispatchNo, reason: '关联本轮客户执行单据', - }, '将客户排车单关联至车次') - const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '读取真实货物目的地匹配键') - const destinations = new Map() - for (const cargo of loading.cargo || []) { - if (!cargo.destinationKey) throw new Error('实际排车货物缺少目的地,不能伪造装载站点') - destinations.set(cargo.destinationKey, { name: cargo.destinationLabel, destinationKey: cargo.destinationKey }) + const proof = { dispatchNo, warehouseId: id(warehouseId), marker: `FT-${randomUUID().slice(0, 12)}`, + syntheticTestMaterials: true, preparation: 'CHECKING', reassignment: { status: 'PENDING' } } + context.createdResources.fleetTrips ??= []; context.createdResources.fleetTrips.push(proof) + const path = `${DISPATCH}/${encodeURIComponent(dispatchNo)}` + let before = await command(context, module, 'GET', path, undefined, '读取原生排车主单版本和阶段') + version(before.recordVersion) + if (before.dispatchNo !== dispatchNo || before.status !== 'ACTIVE' || safePackageCount(before.counts?.pickedPackageCount) !== 0 + || safePackageCount(before.counts?.shippedPackageCount) !== 0) throw new Error('仅本轮有效零扫码排车可准备车辆和审核') + if (before.stage === 'LOADING') { + proof.returnRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'RETURN_TO_REVIEW'), reason: 'TEST零扫码明确退回装车前后调整资源' } + await command(context, module, 'POST', `${path}/return-to-review`, proof.returnRequest, '零扫码排车显式退回装车前') + before = await command(context, module, 'GET', path, undefined, '回查显式退回后的审核状态') } - if (!destinations.size) throw new Error('实际排车没有货物') - detail = await command(context, module, 'PUT', `${path}/plan`, { - recordVersion: detail.trip.recordVersion, plannedDepartureAt, expectedDriverEndAt, - stops: [{ name: '测试起运仓' }, ...destinations.values()], reason: '按实际货物目的地冻结路线', - }, '在拣货前匹配真实卸货站') - proof.stops = detail.trip.stops - detail = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(dispatchNo)}/review`, { - recordVersion: detail.trip.recordVersion, decision: 'APPROVED', - }, '拣货前通过本轮客户排车调度审核') - proof.dispatchReview = detail.dispatches.find(row => row.dispatchNo === dispatchNo)?.review - if (proof.dispatchReview?.status !== 'APPROVED') throw new Error('客户排车调度审核未通过,停止拣货准备') - return proof + if (before.stage !== 'BEFORE_LOADING' || !['DRAFT', 'DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(before.reviewState)) + throw new Error('排车未处于可编辑装车前阶段,不能隐式撤销审核或改派') + const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { + plateNumber: proof.marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100, + region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, + remark: `${context.marker} TEST合成车辆主档,容量不是实载测量`, + }, '建立本轮TEST车辆主档及容量') + proof.vehicleId = id(vehicle.id) + if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID') + await prepareQualifiedDriver(context, module, proof) + proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'), + vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId }, + driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt } }, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: before.dispatchInfo?.estimatedArrivalAt ?? null, + remark: `${context.marker} TEST合成资料正常履约` } + let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机') + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) { + const body = { recordVersion: result.recordVersion, idempotencyKey: key(proof, action), + ...(action === 'submit-review' ? { remark: '本轮TEST整车提交审核' } : { decision: 'APPROVED', reason: '本轮TEST整车审核' }) } + result = await command(context, module, 'POST', `${path}/${action}`, body, `原生整车${action}`) + } + const detail = await command(context, module, 'GET', path, undefined, '审核后读取原生资源快照') + check(context, module, '两级审核通过才进入装车中', detail.status === 'ACTIVE' && detail.stage === 'LOADING' + && detail.reviewState === 'APPROVED' && detail.reviews?.dispatchReview?.status === 'APPROVED' + && detail.reviews?.loadingReview?.status === 'APPROVED' && safePackageCount(detail.counts?.pickedPackageCount) === 0 + && version(detail.recordVersion) === version(result.recordVersion)) + Object.assign(proof, { vehicleInfo: detail.vehicleInfo, vehicle: detail.vehicleInfo?.vehicle, driver: detail.vehicleInfo?.driver, + dispatchReview: detail.reviews.dispatchReview, loadingReview: detail.reviews.loadingReview, recordVersion: detail.recordVersion }) + await discoverTrip(context, module, proof) + proof.preparation = 'VERIFIED'; await context.checkpoint?.(); return proof } -async function prepareQualifiedDriver(context, module, marker, proof) { +async function prepareQualifiedDriver(context, module, proof) { + proof.expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString() let driver = await command(context, module, 'POST', `${BASE}/drivers`, { - name: `${marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', - licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', - licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), - remark: `${context.marker} TEST credential fixture; not a real driving licence`, - }, '建立本轮司机证照档案') + name: `${proof.marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', + licenseNumber: proof.marker, licenseType: 'Class A', licenseZone: 'UTC', + licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), remark: `${context.marker} TEST合成证照资料,不是真实驾驶证`, + }, '建立本轮TEST司机档案') proof.driverId = id(driver.id) - const uploaded = await context.requestMultipart('POST', `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`, { - name: `${marker}-TEST-LICENSE.png`, type: 'image/png', - content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'), - }) - driver = context.apiSuccess(module, '上传本轮测试证照文件', uploaded, '', OPTIONS) - if (!driver?.licenseFileId) throw new Error('测试证照上传失败,不允许伪造已审核资格') + if (!proof.driverId) throw new Error('司机主档没有返回真实资源ID') + proof.licenseUpload = { path: `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`, + name: `${proof.marker}-TEST-LICENSE.png`, syntheticTestMaterials: true, status: 'PREPARED' } + await context.checkpoint?.() + let uploaded + try { + uploaded = await context.requestMultipart('POST', proof.licenseUpload.path, { name: proof.licenseUpload.name, type: 'image/png', + content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') }) + } catch (error) { proof.licenseUpload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error } + Object.assign(proof.licenseUpload, { status: 'RESPONDED', http: uploaded?.http, code: uploaded?.body?.code }); await context.checkpoint?.() + driver = context.apiSuccess(module, '上传TEST合成证照文件', uploaded, '', OPTIONS) + if (uploaded?.http !== 200 || String(uploaded?.body?.code) !== '200' || !driver?.licenseFileId) throw new Error('TEST证照上传失败,不能伪造资格') proof.licenseFileId = id(driver.licenseFileId) const reviewed = await command(context, module, 'POST', `${BASE}/drivers/${proof.driverId}/review`, { - recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅用于本轮受控测试司机', - }, '通过正式命令审核测试司机') - if (reviewed.reviewStatus !== 'APPROVED') throw new Error('测试司机未审核通过,停止派车验证') + recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅本轮TEST合成证照验收', + }, '正式审核本轮TEST司机') + if (reviewed.reviewStatus !== 'APPROVED') throw new Error('TEST司机未审核通过') } -export function controlledPalletMeasurements(cargo, stops) { - const pallets = new Map() - for (const item of cargo) { - if (!item.warehousePalletId || !item.palletNo || !item.destinationKey) throw new Error('装载货物缺少真实托盘或目的地') - const deliveryIndex = stops.findIndex(stop => stop.destinationKey === item.destinationKey) - if (deliveryIndex < 1) throw new Error('货物目的地不在车次路线') - const key = id(item.warehousePalletId) - if (pallets.has(key) && pallets.get(key).deliveryIndex !== deliveryIndex) throw new Error('混合目的地实物托盘须先拆托') - // Controlled test measurement, deliberately separate from source billing/allocated mass. - pallets.set(key, { warehousePalletId: key, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex }) +const decimal = value => String(value).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '') +function evidenceFor(row, field, value) { + return row?.evidence?.some(e => e.field === field && e.actual === true && e.sourceType && e.sourceBusinessNo + && Number.isFinite(Date.parse(e.observedAt)) && decimal(e.value) === decimal(value)) +} +function safePackageCount(value) { + if ((typeof value !== 'number' && typeof value !== 'string') + || !/^(0|[1-9]\d*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) + throw new Error('实际托盘箱数缺失或精度无效') + return Number(value) +} +/** 保留导出名称供直接调用方定位;任何实际量均须来自实测证据。 */ +export function controlledPalletMeasurements(pallets, stops, actualPallets = []) { + if (!Array.isArray(pallets) || !pallets.length || !Array.isArray(stops)) throw new Error('没有实际装载托盘或真实路线') + const supplied = new Map(), seen = new Set() + for (const row of actualPallets) { + if (!row.warehousePalletId || supplied.has(id(row.warehousePalletId))) throw new Error('生产实测回执托盘缺失或重复') + supplied.set(id(row.warehousePalletId), row) } - if (!pallets.size) throw new Error('没有实际装载托盘') - return [...pallets.values()] + const result = pallets.map(row => { + const palletId = id(row.warehousePalletId) + if (!palletId || !row.palletNo || seen.has(palletId)) throw new Error('实际预览托盘缺失或重复') + seen.add(palletId) + const scheduled = safePackageCount(row.scheduledPackages), picked = safePackageCount(row.pickedPackages) + if (scheduled <= 0 || picked !== scheduled) throw new Error('实际托盘拣货尚未核平') + const receipt = supplied.get(palletId), output = { warehousePalletId: palletId } + for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) { + const source = row[field] == null ? receipt : row, value = source?.[field] + if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(value)) || !Number.isFinite(Number(value)) || Number(value) <= 0) throw new Error(`实际测量缺少有效${field}`) + if (!evidenceFor(source, field, value)) throw new Error(`实际测量${field}缺少真实来源证据`) + if (field === 'palletSpaces' && !Number.isSafeInteger(value)) throw new Error('实际托位必须为正整数') + if (row[field] != null && receipt?.[field] != null && decimal(row[field]) !== decimal(receipt[field])) throw new Error(`生产回执与预览${field}不一致`) + output[field] = value + } + const index = row.deliveryIndex ?? receipt?.deliveryIndex + if (!Number.isSafeInteger(index) || index < 1 || !stops[index]?.destinationKey) throw new Error('实际卸货站缺失或不在车次路线') + if (row.deliveryIndex != null && receipt?.deliveryIndex != null && row.deliveryIndex !== receipt.deliveryIndex) throw new Error('生产回执与实际卸货站不一致') + output.deliveryIndex = index; return output + }) + if ([...supplied.keys()].some(palletId => !seen.has(palletId))) throw new Error('生产实测回执包含本车以外托盘') + return result } -export async function confirmFleetLoading(context, module, proof) { +// 已扫码单只能用原生编辑拒绝证明,不能再走旧Fleet强行改派。 +async function verifyPreDepartureReassignment(context, module, proof) { const state = proof.reassignment - if (state && state.status !== 'PENDING') throw new Error('本轮改派检查已开始;保留原请求排查,不自动重复写入') - if (state) state.status = 'CHECKING' - const confirmed = await confirmActualLoad(context, module, proof) - if (!state) return approveLoading(context, module, proof, confirmed.recordVersion) - state.originalConfirmation = confirmed - await verifyPreDepartureReassignment(context, module, proof, confirmed) - const reconfirmed = await confirmActualLoad(context, module, proof) - check(context, module, '重新确认仍覆盖改派前同批货物', reconfirmed.manifestHash === confirmed.manifestHash - && JSON.stringify(reconfirmed.cargo) === JSON.stringify(confirmed.cargo) - && version(reconfirmed.recordVersion) > version(state.assignedTrip.recordVersion)) - state.finalConfirmation = reconfirmed - const reviewedVersion = await approveLoading(context, module, proof, reconfirmed.recordVersion) - state.status = 'VERIFIED' - return reviewedVersion + if (!state) return + if (state.status !== 'PENDING') throw new Error('本轮改派拒绝检查已开始;保留原请求排查,不自动重试') + state.status = 'CHECKING' + const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}` + state.before = await command(context, module, 'GET', path, undefined, '已扫码拒绝前保存主单') + state.itemsBefore = await command(context, module, 'GET', `${path}/items`, undefined, '保存真实扫码与来源') + if (state.before.status !== 'ACTIVE' || state.before.stage !== 'LOADING' || safePackageCount(state.before.counts?.pickedPackageCount) <= 0) throw new Error('拒绝检查缺少本轮真实扫码事实') + state.request = { ...proof.updateRequest, recordVersion: state.before.recordVersion, idempotencyKey: key(proof, 'SCANNED_EDIT_MUST_REJECT'), + plannedDepartureAt: new Date(Date.parse(proof.updateRequest.plannedDepartureAt) + 60000).toISOString(), remark: 'TEST已扫码后编辑资源计划必须拒绝' } + await context.checkpoint?.() + const response = await context.request('PUT', path, state.request) + state.response = { http: response?.http, code: response?.body?.code, message: response?.body?.message } + check(context, module, '已扫码原生排车编辑被服务端拒绝', response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code)))) + state.after = await command(context, module, 'GET', path, undefined, '拒绝后回查主单') + state.itemsAfter = await command(context, module, 'GET', `${path}/items`, undefined, '拒绝后回查真实货物') + check(context, module, '已扫码拒绝后版本资源货物数量不变', JSON.stringify(state.before) === JSON.stringify(state.after) && JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)) + state.status = 'REJECTED_WITHOUT_MUTATION' } -async function approveLoading(context, module, proof, recordVersion) { - const detail = await command(context, module, 'POST', `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading-review`, { - recordVersion, decision: 'APPROVED', - }, '实际装载确认后通过整车理货审核') - proof.loadingReview = detail.loadingReview - if (detail.loadingReview?.status !== 'APPROVED') throw new Error('整车理货审核未通过,不允许发车') - proof.recordVersion = detail.trip.recordVersion - return proof.recordVersion -} - -async function confirmActualLoad(context, module, proof) { - const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading` - const loading = await command(context, module, 'GET', path, undefined, '拣货后读取整车实际装载依据') - if (loading.blockReason) throw new Error(`整车未就绪:${loading.blockReason}`) - const pallets = controlledPalletMeasurements(loading.cargo, proof.stops) - const confirmed = await command(context, module, 'POST', path, { - recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, pallets, - reason: '受控测试测量:每个实物托盘1托位、100kg、1m³;非生产测量', - }, '通过正式命令确认受控测试装载') - proof.confirmedLoad = confirmed.confirmedLoad - proof.recordVersion = confirmed.recordVersion - check(context, module, '装载确认返回当前明细与新版本', confirmed.confirmedLoad?.manifestHash === loading.manifestHash - && version(confirmed.recordVersion) > version(loading.recordVersion) - && JSON.stringify(confirmed.confirmedLoad?.pallets) === JSON.stringify([...pallets].sort((a, b) => a.warehousePalletId.localeCompare(b.warehousePalletId)))) - return confirmed -} - -function dispatchWorkSnapshot(row) { - return JSON.stringify({ dispatchNo: row.dispatchNo, warehouseId: row.overseasWarehouseId, - status: row.status, packageCount: row.packageCount, pickedPackageCount: row.pickedPackageCount, - shippedPackageCount: row.shippedPackageCount, remainingPackageCount: row.remainingPackageCount, - estimatedArrivalAt: row.estimatedArrivalAt, items: row.items }) -} - -async function verifyPreDepartureReassignment(context, module, proof, confirmed) { - const state = proof.reassignment - const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` - const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}` - const original = await command(context, module, 'GET', path, undefined, '改派前读取车次资源和计划') - const before = await command(context, module, 'GET', dispatchPath, undefined, '改派前保留实际拣货与货物明细') - check(context, module, '改派仅针对本轮已拣完但未发车的车次', original.trip?.status === 'PLANNED' - && version(original.trip.recordVersion) === version(confirmed.recordVersion) && before.status === 'READY_TO_DEPART' - && before.dispatchNo === proof.dispatchNo && before.pickedPackageCount > 0 && before.shippedPackageCount === 0 - && before.pickedPackageCount === before.packageCount && before.items?.length > 0 - && id(original.trip.vehicleId) === proof.vehicleId && id(original.trip.driverId) === proof.driverId) - state.originalTrip = original.trip; state.dispatchBefore = before - const marker = `SWAP-${randomUUID().slice(0, 12)}` - const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { - plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0, - region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: original.trip.maxPalletSpaces, - maxLoadKg: original.trip.maxLoadKg, maxVolumeCbm: original.trip.maxVolumeCbm, remark: `${context.marker} 发车前改派测试`, - }, '创建本轮等容量替换车辆') - state.replacement = { vehicleId: id(vehicle.id) } - await prepareQualifiedDriver(context, module, marker, state.replacement) - state.assignmentRequest = { recordVersion: confirmed.recordVersion, vehicleId: state.replacement.vehicleId, - driverId: state.replacement.driverId, reason: '已拣完发车前换车换司机并重新核验装载' } - const assigned = await command(context, module, 'POST', `${path}/assignment`, state.assignmentRequest, '已拣货车次正式更换车辆和司机') - state.assignedTrip = assigned.trip - check(context, module, '改派完成且保持路线与客户关联', id(assigned.trip?.vehicleId) === state.replacement.vehicleId - && id(assigned.trip?.driverId) === state.replacement.driverId && assigned.trip.status === 'PLANNED' - && version(assigned.trip.recordVersion) > version(confirmed.recordVersion) && JSON.stringify(assigned.trip.stops) === JSON.stringify(original.trip.stops) - && JSON.stringify(assigned.dispatches.map(({ review, ...row }) => row)) === JSON.stringify(original.dispatches.map(({ review, ...row }) => row)) - && assigned.dispatches.every(row => row.review?.status === 'PENDING')) - const after = await command(context, module, 'GET', dispatchPath, undefined, '改派后回查实际拣货与货物明细') - state.dispatchAfter = after - check(context, module, '改派只更新车辆司机并保留拣货货量到达时间', dispatchWorkSnapshot(after) === dispatchWorkSnapshot(before) - && after.vehiclePlate === assigned.trip.vehiclePlate && after.driverName === assigned.trip.driverName && after.driverPhone === assigned.trip.driverPhone) - const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '回查改派后原装载确认失效') - check(context, module, '原装载失效但货物和客户归属保持一致', loading.confirmedLoad == null - && version(loading.recordVersion) === version(assigned.trip.recordVersion) && loading.manifestHash === confirmed.manifestHash - && JSON.stringify(loading.cargo) === JSON.stringify(confirmed.cargo)) - state.staleLoadRequest = { recordVersion: confirmed.recordVersion, manifestHash: confirmed.manifestHash, - pallets: confirmed.confirmedLoad.pallets, reason: '旧改派前页面提交必须被拒绝' } - const stale = await context.request('POST', `${path}/loading`, state.staleLoadRequest) - const conflict = stale?.http === 409 || (stale?.http === 200 && ['409', 'A0409'].includes(String(stale?.body?.code))) - state.staleLoadResult = { http: stale?.http, code: stale?.body?.code, message: stale?.body?.message } - check(context, module, '旧版本不能恢复旧装载确认', conflict && /已被修改/.test(String(stale?.body?.message || ''))) - const unchanged = await command(context, module, 'GET', `${path}/loading`, undefined, '拒绝旧版本后回查装载未发生写入') - check(context, module, '旧版本拒绝后仍未确认且版本保持不变', version(unchanged.recordVersion) === version(assigned.trip.recordVersion) - && unchanged.confirmedLoad == null && unchanged.manifestHash === loading.manifestHash) - let reviewed = assigned - for (const binding of assigned.dispatches) { - reviewed = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, { - recordVersion: reviewed.trip.recordVersion, decision: 'APPROVED', - }, '换车换司机后重新通过调度审核') - if (reviewed.dispatches.find(row => row.dispatchNo === binding.dispatchNo)?.review?.status !== 'APPROVED') throw new Error('改派后调度审核未通过') +export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements) { + if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明') + if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试') + const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}` + const preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken) + if (preview.dispatchNo !== proof.dispatchNo || preview.status !== 'ACTIVE' || preview.stage !== 'LOADING' || preview.pickingReconciled !== true + || preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`) + if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker + || actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车') + const measurements = controlledPalletMeasurements(preview.pallets, proof.stops, actualMeasurements?.pallets) + await verifyPreDepartureReassignment(context, module, proof) + proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements + proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion, + idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, measurements, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } } + const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken) + check(context, module, '显式装车才进入装车后', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'AFTER_LOADING' + && confirmed.result?.confirmedLoad?.manifestHash === preview.manifestHash && version(confirmed.recordVersion) > version(preview.recordVersion)) + const returned = confirmed.result.confirmedLoad.pallets + check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length + && measurements.every(m => returned.some(r => id(r.warehousePalletId) === m.warehousePalletId + && r.palletSpaces === m.palletSpaces && decimal(r.weightKg) === decimal(m.weightKg) + && decimal(r.volumeCbm) === decimal(m.volumeCbm) && r.deliveryIndex === m.deliveryIndex))) + proof.loadingCommand.result = confirmed; proof.confirmedLoad = confirmed.result.confirmedLoad + if (pdaToken) { + const replay = await command(context, module, 'POST', `${path}/loading-complete`, proof.loadingCommand.body, '平台原键原载荷重放PDA装车') + check(context, module, '双端装车共用原结果且不增加版本', replay.replayed === true && replay.dispatchNo === confirmed.dispatchNo + && version(replay.recordVersion) === version(confirmed.recordVersion) && JSON.stringify(replay.result) === JSON.stringify(confirmed.result)) + proof.loadingCommand.platformReplay = replay } - state.reviewedDispatches = reviewed.dispatches - proof.vehicleId = state.replacement.vehicleId; proof.driverId = state.replacement.driverId - proof.licenseFileId = state.replacement.licenseFileId + proof.recordVersion = confirmed.recordVersion; await context.checkpoint?.(); return proof.recordVersion } -function check(context, module, label, condition, evidence = '') { - context.assert(module, label, null, condition, evidence, OPTIONS) - if (!condition) throw new Error(`${module}: ${label}失败`) -} - -async function expectOccupied(context, module, path, body, resource, label) { - const response = await context.request('POST', `${path}/assignment`, body) - const conflict = response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code))) - const message = String(response?.body?.message || '') - const valid = conflict && message.includes(resource) && message.includes('占用') - context.assert(module, label, response, valid, `expected occupied ${resource}`, OPTIONS) - if (!valid) throw new Error(`${module}: ${label}未证明资源占用;停止并保留本轮资源`) -} - -/** Fresh-run closure proof only. Retain all commands on proof for diagnostics; do not repair old fixtures. */ +/** 货物结案与车辆司机结束分别执行;不再创建退役Fleet空计划作为探针。 */ export async function finishFleetTrip(context, module, proof) { if (!proof?.tripNo || !proof.vehicleId || !proof.driverId || !proof.warehouseId) throw new Error('缺少本轮车次与资源证明') - if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造或改写请求') - const state = proof.closure = { status: 'CHECKING', probes: [] } - const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` - const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车所有货物结案条件') + if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造') + const state = proof.closure = { status: 'CHECKING', occupancy: [] }, path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` + const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车货物结案条件') check(context, module, '全部货物具有本车结案依据', inspection.canComplete === true && inspection.outcomes?.length > 0, inspection.blockReason || '') state.completeRequest = { recordVersion: inspection.recordVersion, idempotencyKey: `${proof.tripNo}:COMPLETE`, reason: '本轮实际签收及退回终态核对' } - const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案车次货物') - check(context, module, '结案记录原运输完成时间且尚未声明资源释放', completed.trip?.status === 'COMPLETED' - && Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null) - const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '同键重放货物结案') - check(context, module, '结案重放没有新增版本或改变完成时间', replay.trip?.recordVersion === completed.trip.recordVersion && replay.trip.completedAt === completed.trip.completedAt) - - async function probe(index) { - const request = { warehouseId: proof.warehouseId, idempotencyKey: `${proof.tripNo}:RELEASE-PROBE:${index}`, - plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), - stops: proof.stops, remark: '本轮资源占用验收计划,不关联货物或执行发车' } - const evidence = { createRequest: request }; state.probes.push(evidence) - const created = await command(context, module, 'POST', `${BASE}/trips`, request, `建立资源占用核对计划${index}`) - evidence.tripNo = created.trip.tripNo; evidence.recordVersion = created.trip.recordVersion - return { evidence, path: `${BASE}/trips/${encodeURIComponent(evidence.tripNo)}` } + const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案货物') + check(context, module, '结案未自动结束车辆或司机', completed.trip?.status === 'COMPLETED' && Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null) + const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '原键重放结案') + check(context, module, '结案重放不增加版本或改变完成时间', version(replay.trip.recordVersion) === version(completed.trip.recordVersion) && replay.trip.completedAt === completed.trip.completedAt) + async function occupancy(kind, expectedOwner) { + const resourceId = kind === 'VEHICLE' ? proof.vehicleId : proof.driverId + const rows = await command(context, module, 'GET', `${path}/resource-occupancy?${new URLSearchParams({ kind, resourceIds: resourceId })}`, undefined, `读取${kind}独立占用`) + state.occupancy.push({ kind, rows }) + // available对本车为true,必须核对occupiedTripNo的真实拥有者。 + check(context, module, `${kind}占用拥有者符合独立结束阶段`, rows.length === 1 && id(rows[0].resourceId) === id(resourceId) && (rows[0].occupiedTripNo ?? null) === expectedOwner) } - const first = await probe(1) - const assign = { recordVersion: first.evidence.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '验证本轮资源占用' } - await expectOccupied(context, module, first.path, assign, '车辆', '货物结案后车辆仍不能被另一车次占用') - + await occupancy('VEHICLE', proof.tripNo); await occupancy('DRIVER', proof.tripNo) const endLocation = proof.stops?.at(-1)?.name - check(context, module, '测试任务具有明确结束地点', Boolean(endLocation)) - // These controlled fixtures end at the signed final stop and contain no return-driving task. - state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', - actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: 'IN_SERVICE', - idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: '受控测试车辆在末站结束,无返程安排' } - const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '单独结束测试车辆任务') - check(context, module, '只释放车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null) - await expectOccupied(context, module, first.path, assign, '司机', '车辆已释放但司机仍不能被另一车次占用') - - state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER', - actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: null, - idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: '受控测试司机在末站结束,无返程安排' } - const driverReleased = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '单独结束测试司机任务') - check(context, module, '车辆和司机各自保留真实任务结束记录', Boolean(driverReleased.trip?.vehicleRelease && driverReleased.trip.driverRelease)) - const assigned = await command(context, module, 'POST', `${first.path}/assignment`, assign, '释放后正式再次分配相同车辆司机') - first.evidence.recordVersion = assigned.trip.recordVersion - check(context, module, '释放后的两项资源可被下一计划使用', id(assigned.trip?.vehicleId) === id(proof.vehicleId) && id(assigned.trip?.driverId) === id(proof.driverId)) - const oldVehicleReplay = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '资源再分配后重放旧车辆结束请求') - const oldDriverReplay = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '资源再分配后重放旧司机结束请求') - check(context, module, '原结束请求重放不增加旧车次版本', oldVehicleReplay.trip.recordVersion === driverReleased.trip.recordVersion && oldDriverReplay.trip.recordVersion === driverReleased.trip.recordVersion) - const second = await probe(2) - await expectOccupied(context, module, second.path, { ...assign, recordVersion: second.evidence.recordVersion }, '车辆', '旧结束重放不能释放下一计划的车辆占用') - // A different free fixture vehicle isolates the driver claim from the vehicle claim. - const spare = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { - plateNumber: `PROBE-${randomUUID().slice(0,12)}`, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0, - region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, remark: `${context.marker} 仅用于本轮司机占用负向验证`, - }, '建立司机占用隔离用测试车辆') - state.spareVehicleId = id(spare.id) - await expectOccupied(context, module, second.path, { ...assign, vehicleId: state.spareVehicleId, recordVersion: second.evidence.recordVersion }, '司机', '旧结束重放不能释放下一计划的司机占用') - for (const item of [second, first]) { - const cancelled = await command(context, module, 'POST', `${item.path}/cancel`, { recordVersion: item.evidence.recordVersion, reason: '资源占用核对完成,取消无货测试计划' }, '正式取消无货核对计划') - check(context, module, '核对计划取消且保留历史', cancelled.trip?.status === 'CANCELLED') - item.evidence.finalStatus = cancelled.trip.status - } - const final = await command(context, module, 'GET', path, undefined, '回查最终车次结案与资源结束记录') - check(context, module, '车次结案及两项资源结束保持一致', final.trip?.status === 'COMPLETED' && Boolean(final.trip.vehicleRelease && final.trip.driverRelease)) - state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' - state.finalTrip = final.trip - return state + if (!endLocation) throw new Error('TEST运输缺少明确末站结束地点') + state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt, + endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: 'TEST车辆末站任务独立结束,无返程安排' } + const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '独立结束TEST车辆任务') + check(context, module, '只结束车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null) + await occupancy('VEHICLE', null); await occupancy('DRIVER', proof.tripNo) + state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt, + endLocation, vehicleStatus: null, idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: 'TEST司机末站任务独立结束,无返程安排' } + const released = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '独立结束TEST司机任务') + check(context, module, '两项资源各自保留结束记录', Boolean(released.trip?.vehicleRelease && released.trip.driverRelease)) + await occupancy('VEHICLE', null); await occupancy('DRIVER', null) + // 正式空排车是新资源拥有者;不恢复已退役的独立Fleet建单或手工关联。 + state.nextDispatchRequest = { idempotencyKey: key(proof, 'NEXT_NATIVE_DISPATCH'), warehouseId: proof.warehouseId, + vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId }, + driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: new Date(Date.now() + 48 * 3600000).toISOString() } }, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, + remark: `${context.marker} TEST本轮空排车验证旧结束键不能释放新占用`, cargo: [], batchAllocations: [] } + state.nextDispatchResult = await command(context, module, 'POST', DISPATCH, state.nextDispatchRequest, '建立本轮原生空排车占用已结束资源') + if (!state.nextDispatchResult.dispatchNo || state.nextDispatchResult.dispatchNo === proof.dispatchNo) throw new Error('原生空排车未返回独立真实业务号') + state.nextDispatch = { dispatchNo: state.nextDispatchResult.dispatchNo, warehouseId: proof.warehouseId, + marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId } + await discoverTrip(context, module, state.nextDispatch) + if (state.nextDispatch.tripNo === proof.tripNo) throw new Error('新空排车未建立独立资源记录') + await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo) + const oldVehicle = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '原键重放车辆结束') + const oldDriver = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '原键重放司机结束') + check(context, module, '独立结束重放不增加版本', version(oldVehicle.trip.recordVersion) === version(released.trip.recordVersion) && version(oldDriver.trip.recordVersion) === version(released.trip.recordVersion)) + await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo) + const nextPath = `${DISPATCH}/${encodeURIComponent(state.nextDispatch.dispatchNo)}` + state.nextDispatchBeforeCancel = await command(context, module, 'GET', nextPath, undefined, '取消前核齐本轮新空排车当前版本和零实物') + state.nextDispatchItems = await command(context, module, 'GET', `${nextPath}/items`, undefined, '取消前核齐本轮新空排车零货物来源') + const next = state.nextDispatchBeforeCancel + check(context, module, '仅取消本轮已确定且从未执行的原生空单', next.dispatchNo === state.nextDispatch.dispatchNo + && next.status === 'ACTIVE' && next.stage === 'BEFORE_LOADING' && safePackageCount(next.counts?.itemCount) === 0 + && safePackageCount(next.counts?.packageCount) === 0 && safePackageCount(next.counts?.pickedPackageCount) === 0 && safePackageCount(next.counts?.shippedPackageCount) === 0 + && version(next.recordVersion) === version(state.nextDispatchResult.recordVersion) + && state.nextDispatchItems.dispatchNo === next.dispatchNo && state.nextDispatchItems.items?.length === 0 + && version(state.nextDispatchItems.recordVersion) === version(next.recordVersion)) + state.nextDispatchCancelRequest = { recordVersion: next.recordVersion, + idempotencyKey: key(proof, 'CANCEL_NEXT_NATIVE_DISPATCH'), reason: 'TEST原生空排车占用核对完成,保留取消历史' } + const cancelled = await command(context, module, 'POST', `${nextPath}/cancel`, + state.nextDispatchCancelRequest, '正式取消本轮未执行空排车') + check(context, module, '空排车取消并保留历史', cancelled.dispatchNo === state.nextDispatch.dispatchNo && cancelled.status === 'CANCELLED') + state.nextDispatch.finalStatus = cancelled.status + await occupancy('VEHICLE', null); await occupancy('DRIVER', null) + state.finalTrip = (await command(context, module, 'GET', path, undefined, '回查结案及资源结束记录')).trip + check(context, module, '最终结案和资源结束保持一致', state.finalTrip?.status === 'COMPLETED' && Boolean(state.finalTrip.vehicleRelease && state.finalTrip.driverRelease)) + state.subsequentTripClaimProtection = 'VERIFIED_NATIVE_EMPTY_DISPATCH' + state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'; await context.checkpoint?.(); return state } diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs index 0745eec95..f5ecdc3b5 100644 --- a/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs @@ -1,93 +1,134 @@ import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' -import { groupedDispatchMatches, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs' +import { DISPATCH_BASE as BASE, verifyGroupedDispatch, groupedDispatchMatches, createBody, dispatchIo, sourceSelection, version, versionAfter } from './grouped-dispatch-acceptance.mjs' -const candidates = [1, 2].map(n => ({ planId: '11', planPalletId: `2${n}`, warehousePalletId: `3${n}`, - overseasWarehouseId: '44', customerId: '55', inboundCode: `IN-${n}`, packageCount: n })) -const groups = candidates.map((row, n) => ({ draftContainerId: `6${n}`, planPalletIds: [row.planPalletId], returnReceiptIds: [] })) -const results = () => groups.map((group, n) => ({ draftContainerId: group.draftContainerId, - dispatch: { dispatchNo: `DS-${n}`, status: 'PENDING_DEPARTURE', palletCount: 1, - packageCount: candidates[n].packageCount, pickedPackageCount: 0, shippedPackageCount: 0, - items: [{ ...candidates[n], id: `7${n}` }] } })) -test('registered dispatch flow consumes current-run selected candidates before its main draft', () => { - const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8') - const call = source.indexOf('await verifyGroupedDispatch(context, MODULE, selectedCandidates)') - assert.ok(call > source.indexOf('const selectedCandidates')) - assert.ok(call < source.indexOf('const draftToken = `${context.marker}-DISPATCH-DRAFT`')) +const rows = () => [1, 2].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) }, + sourceKey: `PLAN:2${n}`, sourceVersion: `${n}`.repeat(64), customerId: '55', warehouseId: '44', inboundCode: `IN-${n}`, palletNo: `P-${n}`, + destination: { key: `FBA:A${n}`, type: 'FBA', warehouseCode: `A${n}` }, availablePackages: n, appointmentBatches: [], blockReasons: [] })) +function server(fault = '') { + const candidates = rows(), drafts = new Map(), orders = new Map(), ledger = new Map(), calls = [], checks = [] + let serial = 0, containerId = 60, itemId = 70 + const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: 'CURRENT', createdResources: { phase4OverseasPda: { marker: 'CURRENT', finalStatus: 'COMPLETED', customerId: '55', overseasWarehouseId: '44', palletIds: ['31', '32'] } }, + checkpoint: async () => {}, // Explicit offline persistence double; production run supplies atomic disk persistence. + assert: (_module, label, _response, passed) => checks.push({ label, passed }), + async request(method, raw, body) { + calls.push({ method, path: raw, body: structuredClone(body) }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), write = method !== 'GET' + const key = write ? `${method}:${path}:${body?.idempotencyKey}` : null + if (write) { + assert.ok(body?.idempotencyKey, raw) + if (ledger.has(key)) { const old = ledger.get(key); if (old.body !== JSON.stringify(body)) return response(null, 'A0409'); const data = structuredClone(old.data); if (data?.vehicles) data.replayed = true; return response(data) } + } + const remember = data => { ledger.set(key, { body: JSON.stringify(body), data: structuredClone(data) }); return response(data) } + const draft = body?.draftToken ? drafts.get(body.draftToken) : null + if (path === `${BASE}/drafts` && method === 'POST') { assert.deepEqual(Object.keys(body).sort(), ['idempotencyKey', 'warehouseId']); const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', containers: [], sources: [], expiresAt: '2099-01-01' }; drafts.set(d.draftToken, d); return remember(d) } + if (path === `${BASE}/drafts` && method === 'GET') return response([...drafts.values()].filter(d => d.status === 'ACTIVE')) + if (path === `${BASE}/candidates`) { + assert.equal(params.get('warehouseId'), '44') + const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey))) + return response({ complete: true, snapshotAt: '2026-10-19', candidates: candidates.filter(c => !claimed.has(c.sourceKey)) }) + } + if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(body.capacity, 15); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: 15, sourceKeys: [] }); draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/draft-locks/lock`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(body.source.sourceVersion.length, 64); const c = candidates.find(c => c.selection.planPalletId === body.source.planPalletId); assert.ok(c); draft.sources.push(structuredClone(c)); draft.containers.find(g => g.draftContainerId === body.draftContainerId).sourceKeys.push(c.sourceKey); draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/drafts/snapshot`) { assert.equal(body.recordVersion, draft.recordVersion); assert.ok(Array.isArray(body.containers)); assert.equal(Object.hasOwn(body, 'containersJson'), false); draft.containers = structuredClone(body.containers); draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/drafts/pre-arrange`) { + assert.equal(body.recordVersion, draft.recordVersion) + const matches = body.groups.every(g => JSON.stringify(g.dispatch.cargo.map(s => `PLAN:${s.planPalletId}`).sort()) === JSON.stringify(draft.containers.find(c => c.draftContainerId === g.draftContainerId).sourceKeys.toSorted())) + if (!matches) { if (fault === 'reject-mutates-draft') draft.containers[0].sourceKeys = []; return response(null, 'A0409') } + const vehicles = body.groups.map(g => { + assert.equal(g.dispatch.vehicle, null); assert.equal(g.dispatch.driver, null) + const selected = candidates.filter(c => g.dispatch.cargo.some(s => s.planPalletId === c.selection.planPalletId)) + const number = `DS-${orders.size + 1}`, items = selected.map(c => ({ dispatchItemId: String(++itemId), sourceKey: c.sourceKey, sourceVersion: c.sourceVersion, customerId: c.customerId, planPalletId: c.selection.planPalletId, warehousePalletId: c.selection.warehousePalletId, inboundCode: c.inboundCode, destination: c.destination, scheduledPackages: c.availablePackages, pickedPackages: 0, remove: { allowed: true } })) + const o = { dispatchNo: number, recordVersion: 1, items }; orders.set(number, o) + return { draftContainerId: g.draftContainerId, dispatch: { dispatchNo: number, action: 'CREATE', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 1, replayed: false, counts: counts(o) } } + }) + draft.status = 'CONVERTED' + if (fault === 'unknown-convert') return { http: 0, body: null } + if (fault === 'merged-groups') vehicles[1].dispatch.dispatchNo = vehicles[0].dispatch.dispatchNo + return remember({ draftToken: draft.draftToken, vehicles, replayed: false }) + } + const match = path.slice(BASE.length).match(/^\/([^/]+)(?:\/(items|route)(?:\/([^/]+)\/remove)?)?$/) + if (path === BASE && method === 'GET') return response({ records: [...orders.values()].filter(o => o.dispatchNo === params.get('dispatchNo')).map(o => ({ dispatchNo: o.dispatchNo, warehouseId: '44', recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o) })) }) + const o = orders.get(match?.[1]); assert.ok(o, `${method} ${raw}`) + if (method === 'POST' && match[3]) { assert.equal(body.recordVersion, o.recordVersion); o.items = o.items.filter(i => i.dispatchItemId !== match[3]); o.recordVersion++; return remember({ dispatchNo: o.dispatchNo, action: 'REMOVE_CARGO', recordVersion: o.recordVersion, replayed: false }) } + if (match[2] === 'items') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: o.items }) + if (match[2] === 'route') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, stops: [{ index: 0, name: 'Origin', destinationKey: 'ORIGIN:44' }, ...o.items.map((i, n) => ({ index: n + 1, destinationKey: i.destination.key }))] }) + return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o), dispatchInfo: { route: o.items.map(i => i.destination.warehouseCode).join(',') }, + orderDetails: o.items.map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) + } } + return { context, candidates, calls, checks, orders } +} +function counts(o) { return { itemCount: o.items.length, palletCount: o.items.length, packageCount: o.items.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: 0, shippedPackageCount: 0 } } + +test('actual controller and OpenAPI publish versioned server drafts; standalone legacy creation is absent', () => { + const controller = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java', import.meta.url), 'utf8') + const yaml = readFileSync(new URL('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml', import.meta.url), 'utf8') + for (const path of ['/drafts', '/drafts/containers', '/drafts/snapshot', '/draft-locks/lock', '/drafts/pre-arrange']) { assert.ok(controller.includes(`("${path}")`)); assert.ok(yaml.includes(` ${BASE}${path}:`)) } + assert.equal(yaml.includes(` ${BASE}/pre-arrange:`), false) }) -test('two loading groups preserve distinct dispatches and exact physical sources', () => { - assert.equal(groupedDispatchMatches(results(), groups, candidates), true) - assert.equal(groupedDispatchMatches(results().reverse(), groups, candidates), true) - for (const mutate of [r => r.pop(), r => { r[1].dispatch.dispatchNo = r[0].dispatch.dispatchNo }, - r => { r[1].draftContainerId = r[0].draftContainerId }, - r => { r[0].dispatch.items = r[1].dispatch.items }, - r => { r[0].dispatch.packageCount++ }, r => { r[0].dispatch.items[0].warehousePalletId = '999' }, - r => { r[0].dispatch.items[0].inboundCode = 'FOREIGN' }, r => { r[0].dispatch.status = 'PICKING' }]) { - const value = results(); mutate(value); assert.equal(groupedDispatchMatches(value, groups, candidates), false) +test('grouped native protocol proves exact groups, command replay, real readback and versioned source release', async () => { + const h = server(), proof = await verifyGroupedDispatch(h.context, 'offline', h.candidates) + assert.equal(proof.status, 'GROUPED_DISPATCH_VERIFIED'); assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED') + assert.ok(h.checks.every(c => c.passed)); assert.equal(h.orders.size, 2); assert.ok([...h.orders.values()].every(o => !o.items.length)) + assert.equal(h.calls.filter(c => c.path.endsWith('/drafts/pre-arrange')).length, 3) + assert.equal(h.calls.filter(c => c.path.match(/\/items\/\d+\/remove$/)).length, 2) + assert.ok(h.calls.every(c => c.path !== `${BASE}/pre-arrange` && !/\/pick|\/depart|\/transfer-delivery/.test(c.path))) + assert.ok(h.calls.filter(c => c.method !== 'GET' && c.path !== `${BASE}/drafts`).every(c => Object.hasOwn(c.body, 'recordVersion'))) +}) +test('actual grouping receipt rejects merged group identity even when totals match', () => { + const c = rows(), groups = c.map((r, n) => ({ draftContainerId: String(n), sourceKeys: [r.sourceKey] })) + const receipt = { vehicles: c.map((r, n) => ({ draftContainerId: String(n), dispatch: { dispatchNo: `DS-${n}`, action: 'CREATE', status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: { itemCount: 1, palletCount: 1, packageCount: r.availablePackages } } })) } + assert.equal(groupedDispatchMatches(receipt, groups, c), true) + receipt.vehicles[1].dispatch.dispatchNo = receipt.vehicles[0].dispatch.dispatchNo; assert.equal(groupedDispatchMatches(receipt, groups, c), false) +}) +test('missing marker, source version, physical ownership or ambiguous FBA allocation is NOT_RUN before writes', async () => { + for (const mutate of [h => { h.context.createdResources.phase4OverseasPda.marker = 'OLD' }, h => { delete h.candidates[0].sourceVersion }, h => { h.candidates[0].customerId = '999' }, h => { h.candidates[0].warehouseId = '999' }, h => { h.candidates[0].appointmentBatches = [{}, {}] }]) { + const h = server(); mutate(h); assert.equal((await verifyGroupedDispatch(h.context, 'offline', h.candidates)).status, 'NOT_RUN'); assert.equal(h.calls.length, 0) } }) -function server(fault = '') { - let draft, next = 0, arranged = false - const calls = [], checks = [], orders = new Map() - const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline', traceId: 'offline' }) - const context = { marker: 'CURRENT', createdResources: {}, - assert: (_m, label, _r, passed) => checks.push({ label, passed }), - async request(method, path, body) { - calls.push({ method, path, body: structuredClone(body) }) - if (path.endsWith('/drafts/snapshot')) { draft = { draftToken: body.draftToken, containers: [] }; return response(null) } - if (path.endsWith('/drafts/containers')) { - assert.equal(body.capacity, 15, 'formal draft supports 15 or 30 pallet vehicle capacity') - const container = { id: groups[next++].draftContainerId, palletIds: [], capacity: body.capacity } - draft.containers.push(container); return response(container) - } - if (path.endsWith('/draft-locks/lock')) { - draft.containers.find(row => row.id === body.draftContainerId).palletIds.push(body.planPalletId); return response(null) - } - if (path.endsWith('/drafts')) return response(structuredClone(draft ? [draft] : [])) - if (path.endsWith('/drafts/pre-arrange')) { - if (body.groups[0].planPalletIds[0] !== candidates[0].planPalletId) { - if (fault === 'reject-mutates-draft') draft.containers[0].palletIds = [] - return response(null, 'A0409') - } - if (fault === 'unknown-post') return { http: 0, body: null } - if (!arranged) { for (const row of results()) orders.set(row.dispatch.dispatchNo, row.dispatch); arranged = true } - const result = groups.map(group => ({ draftContainerId: group.draftContainerId, - dispatch: structuredClone(orders.get(`DS-${groups.indexOf(group)}`)) })) - if (fault === 'merged-groups') result[1].dispatch.dispatchNo = result[0].dispatch.dispatchNo - return response(result) - } - if (path.endsWith('/draft-locks/release')) { draft = null; return response(null) } - if (path.includes('/candidates?')) return response(candidates) - const match = path.match(/\/dispatches\/(DS-\d)(?:\/items\/(\d+))?$/) - const order = orders.get(match?.[1]); assert.ok(order, path) - if (method === 'DELETE') { order.items = []; order.palletCount = 0; order.packageCount = 0; return response(null) } - return response(structuredClone(order)) - } } - return { context, calls, checks } -} -test('grouped orchestration proves rejection, same-group replay and releases owned unpicked sources', async () => { - const { context, calls, checks } = server() - const proof = await verifyGroupedDispatch(context, 'offline', candidates) - assert.equal(proof.status, 'GROUPED_PREARRANGE_VERIFIED') - assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED') - assert.equal(proof.results.length, 2) - assert.ok(checks.every(row => row.passed)) - assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 3) - assert.equal(calls.filter(row => row.method === 'DELETE').length, 2) - assert.ok(calls.every(row => !/\/depart|\/pick|\/delivery/.test(row.path))) +test('FBA allocations preserve actual BOOKING source and legal version zero; missing version is never guessed', () => { + const c = rows()[0]; c.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:8', customerId: '55', recordVersion: 0, sources: [{ sourceKey: 'BOOKING:ACTUAL', destinationKey: 'FBA:A1', packageCount: 1 }] }] + const body = createBody('CURRENT', 'FBA', [c]); assert.deepEqual(body.batchAllocations[0], { planPalletId: '21', batchKey: 'FBA_APPOINTMENT:8', customerId: '55', sourceKey: 'BOOKING:ACTUAL', destinationKey: 'FBA:A1', packageCount: 1, recordVersion: 0 }) + delete c.appointmentBatches[0].recordVersion; assert.throws(() => createBody('CURRENT', 'FBA', [c]), /权威recordVersion/) + assert.equal(version(0), 0); for (const missing of [null, undefined, '', false, -1]) assert.throws(() => version(missing)) + assert.equal(version('9223372036854775807'), '9223372036854775807'); assert.equal(versionAfter('9007199254740993', '9007199254740992'), true) + assert.equal(versionAfter('9223372036854775806', '9223372036854775807'), false) + assert.throws(() => version('9223372036854775808')); assert.throws(() => version(9007199254740992)) + assert.throws(() => sourceSelection({ planPalletId: '21' }), /原生来源/) }) -test('rejected grouping that mutates the draft stops before any valid pre-arrangement', async () => { - const { context, calls } = server('reject-mutates-draft') - await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原草稿/) - assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 1) - assert.notEqual(context.createdResources.groupedDispatch.status, 'GROUPED_PREARRANGE_VERIFIED') +test('rejection side effects and merged receipts stop without mutating or cleaning more resources', async () => { + for (const fault of ['reject-mutates-draft', 'merged-groups']) { + const h = server(fault); await assert.rejects(verifyGroupedDispatch(h.context, 'offline', h.candidates)) + assert.equal(h.calls.filter(c => /\/items\/\d+\/remove$/.test(c.path)).length, 0) + assert.equal(h.context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW') + } }) -test('unknown successful-write transport result is not automatically retried or deleted', async () => { - const { context, calls } = server('unknown-post') - await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原子创建/) - assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 2) - assert.equal(calls.filter(row => row.method === 'DELETE').length, 0) - assert.equal(calls.filter(row => row.path.endsWith('/draft-locks/release')).length, 0) - assert.equal(context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW') +test('unknown converted write keeps original request keys and forbids fresh writes across sessions', async () => { + const h = server('unknown-convert'); await assert.rejects(verifyGroupedDispatch(h.context, 'offline', h.candidates)) + assert.equal(h.context.createdResources.groupedDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(h.orders.size, 2) + assert.equal(h.calls.filter(c => c.path.endsWith('/drafts/pre-arrange')).length, 2); assert.equal(h.calls.filter(c => /\/remove$/.test(c.path)).length, 0) + const io = dispatchIo(h.context, 'offline', {}, 'ANOTHER'); await assert.rejects(io.command('POST', BASE, {}, 'forbidden'), /停写/) +}) +test('checkpoint failure before send causes no HTTP; callback records original intent before a successful send', async () => { + const context = { marker: 'C', createdResources: {}, request: async () => { throw Error('must not send') }, checkpoint: async () => { throw Error('disk full') } } + const proof = {}, io = dispatchIo(context, 'offline', proof, 'CHECKPOINT'); await assert.rejects(io.command('POST', BASE, { idempotencyKey: 'same' }, 'create'), /disk full/) + assert.equal(proof.requests[0].status, 'NOT_SENT') + let sent = false + const noPersistence = { marker: 'NO_DISK', createdResources: {}, request: async () => { sent = true } } + await assert.rejects(dispatchIo(noPersistence, 'offline', {}, 'NO_DISK').send('POST', BASE, { idempotencyKey: 'must-not-send' }, 'blocked'), /持久化checkpoint/); assert.equal(sent, false) + const phases = [], other = { marker: 'D', createdResources: {}, checkpoint: async () => phases.push('checkpoint'), request: async () => { phases.push('request'); return { http: 200, body: { code: '200', data: { recorded: true } } } } } + await dispatchIo(other, 'offline', {}, 'CHECKPOINT').command('POST', BASE, { idempotencyKey: 'same' }, 'create'); assert.deepEqual(phases, ['checkpoint', 'request', 'checkpoint']) +}) + +test('5xx, transport failure and malformed success stop writes while preserving original diagnostic evidence', async () => { + for (const response of [{ http: 503, raw: 'upstream failure', body: null }, { http: 0, raw: 'connection reset', transportError: { code: 'ECONNRESET' } }, { http: 200, raw: 'unreadable receipt', body: null }, { http: 200, body: { code: '200', data: null } }]) { + let sent = 0 + const context = { marker: 'UNKNOWN', createdResources: {}, checkpoint: async () => {}, request: async () => { sent++; return response } }, proof = {}, io = dispatchIo(context, 'offline', proof, 'UNKNOWN') + await io.send('POST', BASE, { idempotencyKey: 'original-key' }, 'original create') + assert.equal(proof.outcomeUnknown, true); assert.equal(proof.requests[0].status, 'UNKNOWN_RESULT'); assert.equal(proof.requests[0].body.idempotencyKey, 'original-key') + assert.equal(proof.requests[0].raw, response.raw); assert.deepEqual(proof.requests[0].transportError, response.transportError) + await assert.rejects(io.command('POST', BASE, { idempotencyKey: 'fresh-key' }, 'forbidden'), /停写/); assert.equal(sent, 1) + } }) diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs index 2716a0553..16ec5aa51 100644 --- a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs @@ -1,124 +1,265 @@ -const BASE = '/api/platform/v1/last-leg/dispatches' -const OPTIONS = { scenario: 'grouped-dispatch-atomic-draft', deploymentBlocker: true } -const id = value => value == null ? '' : String(value) -const ok = response => response?.http === 200 && String(response.body?.code) === '200' -const sorted = values => [...values].map(id).sort() +import { createHash } from 'node:crypto' +import { mkdir, open, writeFile, rename } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' -export function groupedDispatchMatches(result, groups, candidates) { - if (!Array.isArray(result) || result.length !== groups.length) return false - const dispatchNos = new Set(), seen = new Set() - return result.every(row => { - const group = groups.find(group => id(group.draftContainerId) === id(row.draftContainerId)) - const dispatch = row.dispatch - if (!group || seen.has(id(row.draftContainerId)) || !dispatch?.dispatchNo || dispatchNos.has(dispatch.dispatchNo) - || dispatch.status !== 'PENDING_DEPARTURE' || !Array.isArray(dispatch.items)) return false - seen.add(id(row.draftContainerId)); dispatchNos.add(dispatch.dispatchNo) - const expected = candidates.filter(candidate => group.planPalletIds.includes(id(candidate.planPalletId))) - if (JSON.stringify(sorted(dispatch.items.map(item => item.planPalletId))) !== JSON.stringify(sorted(group.planPalletIds)) - || dispatch.items.length !== group.planPalletIds.length || Number(dispatch.palletCount) !== expected.length - || Number(dispatch.packageCount) !== expected.reduce((sum, item) => sum + Number(item.packageCount), 0)) return false - return dispatch.items.every(item => expected.some(candidate => id(candidate.planPalletId) === id(item.planPalletId) - && id(candidate.warehousePalletId) === id(item.warehousePalletId) - && id(candidate.overseasWarehouseId) === id(item.overseasWarehouseId) - && candidate.inboundCode === item.inboundCode && Number(candidate.packageCount) === Number(item.packageCount))) +export const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches' +export const id = value => value == null ? '' : String(value) +export const canonical = value => JSON.stringify(value) +export const success = response => response?.http === 200 && String(response.body?.code) === '200' +const positiveId = value => /^[1-9]\d*$/.test(id(value)) +const amount = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0 +export class DispatchNotRun extends Error { constructor(message) { super(message); this.notRun = true } } +export function version(value) { + if (!(typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 || typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) + || BigInt(value) > 9223372036854775807n) throw Error('缺少服务器recordVersion或精度无效,不能猜测零版本') + return value +} +export const versionAfter = (next, previous) => BigInt(version(next)) > BigInt(version(previous)) + +// 主run提供真实原子落盘;独立调用者必须显式提供checkpoint,禁止内存意图冒充持久化。 +export async function dispatchEvidenceContext(context) { + if (typeof context.checkpoint === 'function') return context + if (typeof context.marker !== 'string' || !/^[A-Za-z0-9_-]+$/.test(context.marker)) throw new DispatchNotRun('缺少可用于独占检查点的本轮marker') + const path = resolve(dirname(context.options?.output || 'reports/admin-test-env.json'), 'phase4-dispatch-checkpoints', `${context.marker}.json`) + const redact = value => { + if (Array.isArray(value)) return value.map(redact) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value) + .filter(([name]) => !/password|authorization|secret|apiKey|credential|accessToken|refreshToken|idToken|^token$|base64|^bytes$|^content$/i.test(name)) + .map(([name, nested]) => [name, redact(nested)])) + return value + } + await mkdir(dirname(path), { recursive: true }) + let reservation + try { reservation = await open(path, 'wx') } + catch (error) { if (error.code === 'EEXIST') throw Error(`已存在本轮排车检查点,须核对原请求结果,禁止自动重跑:${path}`); throw error } + await reservation.close() + const scoped = Object.create(context) + scoped.checkpoint = async () => { + const resources = context.createdResources + const evidence = redact({ marker: context.marker, boundary: resources.dispatchWriteBoundary, + phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch }) + await writeFile(`${path}.tmp`, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8') + await rename(`${path}.tmp`, path) + } + context.createdResources.phase4OverseasDispatch.checkpointPath = path + await scoped.checkpoint() + return scoped +} +export function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') params.set(key, String(value)) + return `${path}?${params}` +} +export function commandKey(marker, namespace, action) { + return `D4-${createHash('sha256').update(`${marker}\n${namespace}\n${action}`).digest('hex').slice(0, 48)}` +} +// 仅接受当前Candidate DTO;来源摘要、客户、仓与FBA批次均取生产回执。 +export function sourceSelection(candidate) { + const source = candidate?.selection + if (!positiveId(candidate?.customerId) || !positiveId(candidate?.warehouseId) || !amount(candidate?.availablePackages) + || !source || !/^[a-f\d]{64}$/i.test(source.sourceVersion || '') || source.sourceVersion !== candidate.sourceVersion + || !positiveId(source.warehousePalletId) || !Array.isArray(candidate.blockReasons) || candidate.blockReasons.length + || !Array.isArray(candidate.appointmentBatches) || !candidate.destination?.key) throw new DispatchNotRun('缺少完整、可选的本轮原生来源回执') + const expected = source.sourceType === 'INBOUND_PLAN' && positiveId(source.planPalletId) && source.returnReceiptId == null && source.relabelNo == null ? `PLAN:${source.planPalletId}` + : source.sourceType === 'RETURN_RECEIPT' && positiveId(source.returnReceiptId) && source.planPalletId == null && source.relabelNo == null ? `RETURN:${source.returnReceiptId}` + : source.sourceType === 'RELABEL' && source.relabelNo && source.planPalletId == null && source.returnReceiptId == null ? `RELABEL:${source.relabelNo}:${source.warehousePalletId}` : null + if (!expected || candidate.sourceKey !== expected) throw new DispatchNotRun('来源选择器或服务器来源键不完整') + return { sourceType: source.sourceType, planPalletId: source.planPalletId, returnReceiptId: source.returnReceiptId, + relabelNo: source.relabelNo, warehousePalletId: source.warehousePalletId, sourceVersion: source.sourceVersion } +} +export function createBody(marker, namespace, candidates) { + if (!candidates.length || new Set(candidates.map(c => id(c.warehouseId))).size !== 1) throw new DispatchNotRun('整车缺少同仓真实货物') + const cargo = candidates.map(sourceSelection), batchAllocations = [] + for (const candidate of candidates) { + const batches = candidate.appointmentBatches + if (!batches.length) continue + if (batches.length !== 1 || !positiveId(candidate.selection.planPalletId)) throw new DispatchNotRun('来源跨多个FBA批次,缺少本轮明确逐批实物分配,不能推算') + const batch = batches[0] + if (!/^FBA_APPOINTMENT:[1-9]\d*$/.test(batch.batchKey || '') || id(batch.customerId) !== id(candidate.customerId) + || !Array.isArray(batch.sources) || !batch.sources.length || batch.sources.some(s => !/^(BOOKING:.+|ORDER:[1-9]\d*)$/.test(s.sourceKey || '') || !s.destinationKey?.startsWith('FBA:') || !amount(s.packageCount)) + || batch.sources.reduce((sum, s) => sum + Number(s.packageCount), 0) !== Number(candidate.availablePackages)) throw new DispatchNotRun('FBA批次未完整覆盖本托真实逐票货物') + let batchVersion + try { batchVersion = version(batch.recordVersion) } catch { throw new DispatchNotRun('FBA批次缺少权威recordVersion') } + for (const source of batch.sources) batchAllocations.push({ planPalletId: candidate.selection.planPalletId, batchKey: batch.batchKey, + customerId: candidate.customerId, sourceKey: source.sourceKey, destinationKey: source.destinationKey, + packageCount: Number(source.packageCount), recordVersion: batchVersion }) + } + return { idempotencyKey: commandKey(marker, namespace, 'create'), warehouseId: candidates[0].warehouseId, + vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, + remark: `${marker}-${namespace}`, cargo, batchAllocations } +} +export function markerCandidates(context, candidates) { + const fixture = context.createdResources?.phase4OverseasPda + const pallets = new Set([...(fixture?.palletIds || []), ...Object.values(fixture?.generatedPalletsByInbound || {}).flat().filter(p => id(p.warehouseId) === id(fixture?.overseasWarehouseId)).map(p => p.palletId)].map(id)) + if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED' || !positiveId(fixture?.customerId) || !positiveId(fixture?.overseasWarehouseId)) throw new DispatchNotRun('缺少本轮PDA完成回执及真实客户仓') + return candidates.filter(candidate => { + try { sourceSelection(candidate) } catch { return false } + return candidate.selection.sourceType === 'INBOUND_PLAN' && pallets.has(id(candidate.selection.warehousePalletId)) + && id(candidate.customerId) === id(fixture.customerId) && id(candidate.warehouseId) === id(fixture.overseasWarehouseId) + && candidate.destination.type === 'FBA' }) } - -// Reuses only current-run unpicked stock; independent empty dispatch records remain as audit evidence. -export async function verifyGroupedDispatch(context, module, candidates) { - if (!Array.isArray(candidates) || candidates.length < 2 - || new Set(candidates.map(row => id(row.planPalletId))).size !== candidates.length - || new Set(candidates.map(row => id(row.warehousePalletId))).size !== candidates.length - || candidates.some(row => ['planId', 'planPalletId', 'warehousePalletId', 'overseasWarehouseId', 'customerId'] - .some(key => typeof row[key] !== 'string' || !/^[1-9]\d*$/.test(row[key])) - || typeof row.inboundCode !== 'string' || !row.inboundCode.trim() - || !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) < 1)) { - throw Error('分组预排缺少本轮两个独立实物托盘') - } - const draftToken = `${context.marker}-GROUPED`.slice(0, 64) - const proof = { marker: context.marker, draftToken, status: 'CHECKING', groups: [], results: [], cleanup: 'PENDING' } - context.createdResources.groupedDispatch = proof - const check = (label, response, passed) => { - context.assert(module, label, response, passed, `draftToken=${draftToken}`, OPTIONS) - if (!passed) throw Error(`分组预排:${label}`) - } - const request = async (method, path, body) => { - let response - try { response = await context.request(method, path, body) } - catch (error) { - if (!['GET', 'HEAD'].includes(method)) { - proof.outcomeUnknown = true - proof.cleanup = 'PRESERVED_FOR_REVIEW' - } - throw error - } - if (!['GET', 'HEAD'].includes(method) && (!response?.http || response.http >= 500)) { - proof.outcomeUnknown = true - proof.cleanup = 'PRESERVED_FOR_REVIEW' - } - return response - } - const command = async (method, path, body, label) => { - const response = await request(method, path, body) - check(label, response, ok(response)); return response.body.data - } - const ownDraft = async () => { - const rows = await command('GET', `${BASE}/drafts`, undefined, '读取分组预排草稿作用域') - if (!Array.isArray(rows)) throw Error('分组预排草稿返回格式无效') - return rows.filter(row => row.draftToken === draftToken) - } - try { - await command('POST', `${BASE}/drafts/snapshot`, { draftToken, - planIds: [...new Set(candidates.map(row => id(row.planId)))], containersJson: '[]' }, '保存本轮分组草稿') - for (const candidate of candidates) { - const container = await command('POST', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }, '建立独立装车分组') - if (!container?.id) throw Error('分组缺少服务端柜ID') - const group = { draftContainerId: id(container.id), planPalletIds: [id(candidate.planPalletId)], returnReceiptIds: [] } - proof.groups.push(group) - await command('POST', `${BASE}/draft-locks/lock`, { draftToken, draftContainerId: group.draftContainerId, - planPalletId: group.planPalletIds[0] }, '将本轮托盘绑定到所属装车分组') - } - const before = JSON.stringify(await ownDraft()) - const swapped = proof.groups.map((group, index) => ({ ...group, - planPalletIds: proof.groups[(index + 1) % proof.groups.length].planPalletIds })) - const rejected = await request('POST', `${BASE}/drafts/pre-arrange`, { draftToken, groups: swapped }) - check('来源与分组不一致明确拒绝A0409', rejected, - [200, 409].includes(rejected.http) && String(rejected.body?.code) === 'A0409') - check('分组拒绝后原草稿全部来源与绑定不变', null, JSON.stringify(await ownDraft()) === before) - const body = { draftToken, groups: proof.groups } - const result = await command('POST', `${BASE}/drafts/pre-arrange`, body, '原子创建每组独立客户排车') - if (Array.isArray(result)) proof.results = result - check('每组一张排车且实物托盘与数量不跨组', null, groupedDispatchMatches(result, proof.groups, candidates)) - const replay = await command('POST', `${BASE}/drafts/pre-arrange`, body, '同草稿同分组重放') - check('分组重放保留原排车业务号与明细', null, JSON.stringify(replay) === JSON.stringify(result)) - proof.status = 'GROUPED_PREARRANGE_CHECKED' - } finally { - // Only release resources whose successful response identified an owned dispatch; never retry an unknown POST. - if (!proof.outcomeUnknown) { - for (const row of proof.results) { - const dispatchNo = row.dispatch?.dispatchNo - if (!dispatchNo) continue - const detail = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '清理前回查分组排车仍未拣货') - check('仅清理本轮分组未拣托盘', null, detail?.status === 'PENDING_DEPARTURE' - && groupedDispatchMatches([{ ...row, dispatch: detail }], - proof.groups.filter(group => id(group.draftContainerId) === id(row.draftContainerId)), candidates) - && detail.items.every(item => /^[1-9]\d*$/.test(id(item.id))) - && Number(detail.pickedPackageCount) === 0 && Number(detail.shippedPackageCount) === 0) - for (const item of detail.items) { - await command('DELETE', `${BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.id))}`, - undefined, '移除分组测试明细释放正式托盘锁') - } - const empty = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '回查分组对照单明细归零') - check('分组对照排车释放后数量归零', null, - empty.items?.length === 0 && Number(empty.palletCount) === 0 && Number(empty.packageCount) === 0) - } - await command('POST', `${BASE}/draft-locks/release`, { draftToken }, '释放本轮分组草稿') - const available = await command('GET', `${BASE}/candidates?sourceStage=RECEIVED`, undefined, '分组检查后回查托盘可继续主链预排') - check('分组验收来源回到正式候选', null, Array.isArray(available) - && candidates.every(candidate => available.some(row => id(row.planPalletId) === id(candidate.planPalletId)))) - proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED' - } - } - proof.status = 'GROUPED_PREARRANGE_VERIFIED' +export function notRun(context, proof, reason) { + proof.status = 'NOT_RUN'; proof.reason = reason + context.addCoverageGap?.('phase4-overseas-dispatch', 'native-dispatch', '缺少当前生产前置', reason, 'fixture', { deploymentBlocker: true }) return proof } +// 请求意图必须先持久化,未知写结果使同marker后续写入全部停止。 +export function dispatchIo(context, module, proof, namespace) { + context.createdResources ??= {}; proof.requests ??= [] + const boundary = context.createdResources.dispatchWriteBoundary ??= { marker: context.marker, stopped: false } + if (!context.marker || boundary.marker !== context.marker) throw new DispatchNotRun('请求边界不属于本轮marker') + const checkpoint = async () => { if (typeof context.checkpoint === 'function') await context.checkpoint() } + const check = (label, response, passed) => { + context.assert?.(module, label, response, passed, `marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true }) + if (!passed) throw Error(`${namespace}: ${label}`) + } + const send = async (method, path, body, label) => { + const write = !['GET', 'HEAD'].includes(method) + if (write && boundary.stopped) throw Error('本轮写请求结果未知或回执存档失败,停写并保留原键') + if (write && typeof context.checkpoint !== 'function') throw new DispatchNotRun('缺少真实持久化checkpoint,未发送排车写请求') + const entry = { method, path, ...(body === undefined ? {} : { body: structuredClone(body) }), label, status: 'SENDING' } + proof.requests.push(entry) + try { await checkpoint() } catch (error) { boundary.stopped = true; entry.status = 'NOT_SENT'; throw error } + let response + try { response = await context.request(method, path, body) } + catch (error) { entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; if (write) { boundary.stopped = true; boundary.outcomeUnknown = true; proof.outcomeUnknown = true; proof.cleanup = 'PRESERVED_FOR_REVIEW' }; await checkpoint(); throw error } + const unknown = !response?.http || response.http >= 500 || write && (response.body?.code == null || success(response) && response.body.data == null) + Object.assign(entry, { status: unknown ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http, + code: response?.body?.code, message: response?.body?.message, raw: response?.raw, transportError: response?.transportError, + requestId: response?.requestId, traceId: response?.traceId, result: structuredClone(response?.body?.data) }) + if (write && entry.status === 'UNKNOWN_RESULT') { boundary.stopped = true; boundary.outcomeUnknown = true; proof.outcomeUnknown = true; proof.cleanup = 'PRESERVED_FOR_REVIEW' } + try { await checkpoint() } catch (error) { if (write) boundary.stopped = true; throw error } + return response + } + const command = async (method, path, body, label) => { const response = await send(method, path, body, label); check(label, response, success(response)); return response.body.data } + const key = action => commandKey(context.marker, namespace, action) + return { check, send, command, key } +} +export function replayFacts(result) { const copy = structuredClone(result); if (copy && typeof copy === 'object') delete copy.replayed; return copy } +export async function replay(io, path, body, result, label, method = 'POST') { + const again = await io.command(method, path, body, label) + io.check(`${label}恢复原命令结果`, null, canonical(replayFacts(again)) === canonical(replayFacts(result))) + return again +} +export async function candidatePage(io, warehouseId, extra = {}) { + const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, { warehouseId, sourceStage: 'RECEIVED', ...extra }), undefined, '读取同仓原生候选') + io.check('候选完整包络', null, result?.complete === true && Array.isArray(result.candidates)) + return result.candidates +} +export async function readDispatch(io, dispatchNo) { + const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}` + const data = await io.command('GET', path, undefined, '回查原生整车详情') + const items = await io.command('GET', `${path}/items`, undefined, '回查真实货物明细') + const route = await io.command('GET', `${path}/route`, undefined, '回查真实起点与目的地') + const page = await io.command('GET', query(DISPATCH_BASE, { page: 1, size: 20, dispatchNo }), undefined, '按业务号核对整车仓库') + const rows = page?.records?.filter(r => r.dispatchNo === dispatchNo) || [] + io.check('主单、货物、路线版本与实际仓一致', null, data?.dispatchNo === dispatchNo && items?.dispatchNo === dispatchNo && route?.dispatchNo === dispatchNo + && rows.length === 1 && positiveId(rows[0].warehouseId) && id(version(data.recordVersion)) === id(version(items.recordVersion)) + && id(version(route.recordVersion)) === id(version(data.recordVersion)) && canonical(rows[0].counts) === canonical(data.counts) + && id(rows[0].recordVersion) === id(data.recordVersion) && rows[0].status === data.status && rows[0].stage === data.stage + && items.status === data.status && items.stage === data.stage) + return { data, items, route, warehouseId: rows[0].warehouseId } +} +export function dispatchMatches(bundle, candidates) { + const items = bundle?.items?.items, counts = bundle?.data?.counts + if (!Array.isArray(items) || !counts || !Array.isArray(candidates) || bundle.data.status !== 'ACTIVE' || bundle.data.stage !== 'BEFORE_LOADING' + || items.length !== candidates.length || Number(counts.itemCount) !== items.length || Number(counts.palletCount) !== new Set(candidates.map(c => id(c.selection.warehousePalletId))).size + || Number(counts.packageCount) !== candidates.reduce((n, c) => n + Number(c.availablePackages), 0) || Number(counts.pickedPackageCount) !== 0 || Number(counts.shippedPackageCount) !== 0) return false + const details = bundle.data.orderDetails + if (!Array.isArray(details) || new Set(details.map(d => d.sourceKey)).size !== candidates.length + || details.some(d => !candidates.some(c => c.sourceKey === d.sourceKey && id(c.customerId) === id(d.customerId) + && c.inboundCode === d.inboundCode && c.destination.warehouseCode === d.destinationFbaWarehouse))) return false + return new Set(items.map(i => id(i.dispatchItemId))).size === items.length && new Set(items.map(i => i.sourceKey)).size === items.length && items.every(i => positiveId(i.dispatchItemId) && candidates.some(c => + c.sourceKey === i.sourceKey && c.sourceVersion === i.sourceVersion && id(c.customerId) === id(i.customerId) && id(c.warehouseId) === id(bundle.warehouseId) + && id(c.selection.warehousePalletId) === id(i.warehousePalletId) && id(c.selection.planPalletId) === id(i.planPalletId) + && c.inboundCode === i.inboundCode && c.destination.key === i.destination?.key && Number(c.availablePackages) === Number(i.scheduledPackages) && Number(i.pickedPackages) === 0)) +} +export function groupedDispatchMatches(result, groups, candidates) { + if (!Array.isArray(result?.vehicles) || result.vehicles.length !== groups.length) return false + const seen = new Set(), numbers = new Set() + return result.vehicles.every(row => { + const group = groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)), command = row.dispatch + const selected = candidates.filter(c => group?.sourceKeys.includes(c.sourceKey)) + if (!group || seen.has(id(row.draftContainerId)) || numbers.has(command?.dispatchNo) || !command?.dispatchNo || command.action !== 'CREATE' || command.status !== 'ACTIVE' || command.stage !== 'BEFORE_LOADING') return false + seen.add(id(row.draftContainerId)); numbers.add(command.dispatchNo) + return Number(command.counts?.itemCount) === selected.length && Number(command.counts?.palletCount) === selected.length + && Number(command.counts?.packageCount) === selected.reduce((n, c) => n + Number(c.availablePackages), 0) + }) +} +export async function newDraft(io, warehouseId) { + const body = { idempotencyKey: io.key('draft-create'), warehouseId } + const draft = await io.command('POST', `${DISPATCH_BASE}/drafts`, body, '创建服务器草稿token') + io.check('服务器新草稿身份及零版本', null, Boolean(draft?.draftToken) && id(draft.warehouseId) === id(warehouseId) && id(version(draft.recordVersion)) === '0' && Array.isArray(draft.containers) && Array.isArray(draft.sources)) + await replay(io, `${DISPATCH_BASE}/drafts`, body, draft, '创建草稿同键重放') + return draft +} +export async function activeDraft(io, token) { + const rows = await io.command('GET', `${DISPATCH_BASE}/drafts`, undefined, '回查当前拥有者草稿') + io.check('原服务器草稿唯一存在', null, Array.isArray(rows) && rows.filter(d => d.draftToken === token).length === 1) + return rows.find(d => d.draftToken === token) +} +// 分组全成功才移除已证明属于本轮的未拣实物;失败或未知响应保留所有已成功资源。 +export async function verifyGroupedDispatch(context, module, candidates) { + const proof = { marker: context.marker, status: 'CHECKING', groups: [], results: [], cleanup: 'PRESERVED_FOR_REVIEW', requests: [] } + if (context.createdResources?.groupedDispatch?.requests?.length) throw Error('本轮分组尝试已经存在,禁止自动重造') + context.createdResources ??= {}; context.createdResources.groupedDispatch = proof + try { + const owned = markerCandidates(context, candidates) + if (owned.length !== candidates.length || candidates.length < 2 || new Set(candidates.map(c => c.sourceKey)).size !== candidates.length + || new Set(candidates.map(c => id(c.selection?.warehousePalletId))).size !== candidates.length) throw new DispatchNotRun('分组须有本轮两个独立未拣实托') + candidates.forEach((c, n) => createBody(context.marker, `GROUP-${n}`, [c])) + const io = dispatchIo(context, module, proof, 'GROUPED') + let draft = await newDraft(io, candidates[0].warehouseId); proof.draftToken = draft.draftToken + for (const [n, candidate] of candidates.entries()) { + const previous = new Set(draft.containers.map(c => id(c.draftContainerId))) + draft = await io.command('POST', `${DISPATCH_BASE}/drafts/containers`, { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(`container-${n}`), draftToken: draft.draftToken, capacity: 15 }, '创建真实草稿容器') + const additions = draft.containers.filter(c => !previous.has(id(c.draftContainerId))) + io.check('容器身份来自服务器草稿', null, additions.length === 1 && positiveId(additions[0].draftContainerId)) + const group = { draftContainerId: additions[0].draftContainerId, sourceKeys: [candidate.sourceKey] }; proof.groups.push(group) + const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(`lock-${n}`), draftToken: draft.draftToken, draftContainerId: group.draftContainerId, source: sourceSelection(candidate) } + draft = await io.command('POST', `${DISPATCH_BASE}/draft-locks/lock`, body, '锁定完整真实来源到原容器') + await replay(io, `${DISPATCH_BASE}/draft-locks/lock`, body, draft, '锁定同键重放') + } + const snapshot = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('snapshot'), draftToken: draft.draftToken, planIds: [], containers: draft.containers } + draft = await io.command('POST', `${DISPATCH_BASE}/drafts/snapshot`, snapshot, '保存版本化完整容器布局') + await replay(io, `${DISPATCH_BASE}/drafts/snapshot`, snapshot, draft, '布局同键重放') + const groups = proof.groups.map((g, n) => ({ draftContainerId: g.draftContainerId, dispatch: createBody(context.marker, `GROUPED-CHILD-${n}`, candidates.filter(c => g.sourceKeys.includes(c.sourceKey))) })) + const before = await activeDraft(io, draft.draftToken) + const wrong = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert-reject'), draftToken: draft.draftToken, + groups: groups.map((g, n) => ({ ...g, dispatch: groups[(n + 1) % groups.length].dispatch })) } + const rejected = await io.send('POST', `${DISPATCH_BASE}/drafts/pre-arrange`, wrong, '错组转换拒绝') + io.check('错组来源明确冲突', rejected, [200, 409].includes(rejected?.http) && String(rejected.body?.code) === 'A0409') + io.check('拒绝后原草稿不变', null, canonical(await activeDraft(io, draft.draftToken)) === canonical(before)) + const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert'), draftToken: draft.draftToken, groups } + const converted = await io.command('POST', `${DISPATCH_BASE}/drafts/pre-arrange`, body, '原子转换一容器一整车') + io.check('真实分组命令数量与货物核平', null, converted?.draftToken === draft.draftToken && groupedDispatchMatches(converted, proof.groups, candidates)) + proof.results = converted.vehicles + for (const row of proof.results) { + const selected = candidates.filter(c => proof.groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)).sourceKeys.includes(c.sourceKey)) + const current = await readDispatch(io, row.dispatch.dispatchNo) + io.check('成功创建后逐车回查真实来源', null, dispatchMatches(current, selected)) + } + await replay(io, `${DISPATCH_BASE}/drafts/pre-arrange`, body, converted, '整批转换同键重放') + for (const row of proof.results) { + let current = await readDispatch(io, row.dispatch.dispatchNo) + const selected = candidates.filter(c => proof.groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)).sourceKeys.includes(c.sourceKey)) + io.check('清理前仍为本轮完整未拣货物', null, dispatchMatches(current, selected)) + for (const item of current.items.items) { + io.check('服务端允许移出本轮实物', null, item.remove?.allowed === true) + const remove = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key(`remove-${item.dispatchItemId}`), reason: `${context.marker}-分组验收释放未拣实物` } + await io.command('POST', `${DISPATCH_BASE}/${encodeURIComponent(row.dispatch.dispatchNo)}/items/${encodeURIComponent(item.dispatchItemId)}/remove`, remove, '版本化移出本轮分组实物') + current = await readDispatch(io, row.dispatch.dispatchNo) + } + io.check('空对照单真实数量归零', null, dispatchMatches(current, [])) + } + const available = await candidatePage(io, candidates[0].warehouseId) + io.check('原来源回到可选集合', null, candidates.every(c => available.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) + proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED'; proof.status = 'GROUPED_DISPATCH_VERIFIED'; await context.checkpoint() + return proof + } catch (error) { + if (error.notRun && !proof.requests.length) return notRun(context, proof, error.message) + proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message + throw error + } +} diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index d03ec8a05..93e69d6f6 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import test from 'node:test' import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs' @@ -17,7 +18,7 @@ test('种子解析覆盖多行多值、函数括号及带逗号和引号的文 }) test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 214]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 228]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -76,7 +77,47 @@ test('退回实收按真实流水唯一保存,重派拣货必须明确唯一 } const pick = table(overseas, 'baiyue_lastleg', 'll_dispatch_pda_pick_order') assert.match(pick, /\breceipt_id BIGINT NULL/i) - assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL\) \+ \(return_receipt_id IS NOT NULL\) \+ \(return_carton_id IS NOT NULL\) = 1\)/i) + assert.match(pick, /\bdispatch_item_source_id BIGINT NULL/i) + assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL\) \+ \(return_receipt_id IS NOT NULL\) \+ \(return_carton_id IS NOT NULL\) \+ \(dispatch_item_source_id IS NOT NULL\) = 1\)/i) +}) + +test('同实托初始化分离无代表客户的物理占用与逐来源占用', () => { + const physical = table(overseas, 'baiyue_lastleg', 'll_dispatch_physical_pallet_lock') + assert.doesNotMatch(physical, /\bcustomer_id\b/i) + assert.match(physical, /UNIQUE KEY[^\n]*\(active_warehouse_pallet_id\)/i) + assert.match(physical, /CHECK\(\(status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id\)/i) + assert.match(physical, /dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL/i) + assert.match(physical, /dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL/i) + const logical = table(overseas, 'baiyue_lastleg', 'll_dispatch_pallet_lock') + assert.match(logical, /physical_lock_id BIGINT NULL/i) + assert.match(logical, /UNIQUE KEY[^\n]*\(customer_id,active_key\)/i) + assert.doesNotMatch(logical, /active_warehouse_pallet_id/i) + assert.match(table(overseas, 'baiyue_lastleg', 'll_dispatch_item'), /UNIQUE KEY[^\n]*\(dispatch_order_id,source_key\)/i) + const source = table(overseas, 'baiyue_lastleg', 'll_dispatch_item_source') + assert.match(source, /UNIQUE KEY[^\n]*\(dispatch_item_id,relabel_item_id,inventory_position_id\)/i) + assert.match(source, /product_quantity BIGINT NULL/i) + assert.match(source, /source_inbound_order_id BIGINT NULL/i) + assert.match(source, /relabel_round_key VARCHAR\(128\) NOT NULL/i) + assert.match(source, /unit_facts_json JSON NOT NULL/i) +}) + +test('实托组成串行点只保存真实主键和首次创建时间', () => { + const guard = table(overseas, 'baiyue_lastleg', 'll_physical_pallet_guard') + assert.match(guard, /warehouse_pallet_id BIGINT NOT NULL/i) + assert.match(guard, /created_at DATETIME\(3\) NOT NULL DEFAULT CURRENT_TIMESTAMP\(3\)/i) + assert.match(guard, /PRIMARY KEY\s*\(warehouse_pallet_id\)/i) + assert.doesNotMatch(guard, /\b(?:customer_id|status|expires_at|dispatch_order_id|draft_token|draft_container_id)\b/i) +}) + +test('同实托六表定义在模块和统一空库入口保持一致', () => { + const module = 'USE baiyue_lastleg;\n' + readFileSync(new URL('../../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8') + .replace(/^[ \t]*CREATE TABLE IF NOT EXISTS /gm, 'CREATE TABLE ') + .replace(/^[ \t]+\) ENGINE=/gm, ') ENGINE=') + const body = value => value.slice(value.indexOf('('), value.lastIndexOf(') ENGINE=')) + .replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').trim() + for (const name of ['ll_dispatch_item', 'll_dispatch_item_source', 'll_physical_pallet_guard', 'll_dispatch_physical_pallet_lock', 'll_dispatch_pallet_lock', 'll_dispatch_pda_pick_order']) { + assert.equal(body(table(overseas, 'baiyue_lastleg', name)), body(table(module, 'baiyue_lastleg', name)), name) + } }) test('车辆报价与实测记录保留业务去重和不可变版本', () => { @@ -133,7 +174,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 433, iam_role_permission: 614, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 434, iam_role_permission: 618, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -160,8 +201,15 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const row of permissions) if (row.parent_id && row.parent_id !== '0') assert.ok(permissionIds.has(row.parent_id), 'missing parent ' + row.parent_id) const merchant = roleAuthorities(cn, 'MERCHANT_OWNER') for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement', - 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) + 'merchant:warehouse:delivery:list']) assert.ok(merchant.has(code), code) + assert.ok(!permissions.some(row => row.permission_code === 'merchant:warehouse:delivery:create')) + assert.ok(!merchant.has('merchant:warehouse:delivery:create')) const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') + for (const action of ['create', 'loading-complete', 'depart']) { + const code = `platform:overseas-warehouse:dispatch:${action}` + assert.ok(admin.has(code), code) + assert.ok(!merchant.has(code), code) + } assert.ok(admin.has('platform:overseas-warehouse:discard:approve'), 'discard review endpoint must be grantable to platform administrator') for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) diff --git a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs index d4c47053d..ee6d52004 100644 --- a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs +++ b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs @@ -39,16 +39,22 @@ test('transfer review integration covers both source types and known blank-reaso test('draft and removal checks use actual IDs plus readback rather than snapshot-only success', async () => { const source = await read('phase4-overseas-dispatch.mjs') - for (const value of ['drafts/containers', 'draftContainerId: containerId', 'container.palletIds', - 'draft-locks/unlock', 'deletedDrafts', 'removableItem.id', 'removedDetail.data?.packageCount', 'freed.data.some']) + for (const value of ['drafts/containers', 'draftContainerId: temporary', 'draft.containers', + 'draft-locks/unlock', 'drafts/pre-arrange', 'item.dispatchItemId', 'current.data.counts.palletCount', + 'available.some(c => c.sourceKey === duplicate.sourceKey', 'dispatchBusinessSnapshot(await readDispatch']) assert.ok(source.includes(value), value) }) test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => { const source = await read('phase5-overseas-fulfillment.mjs') for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery', - 'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map']) + 'replay.data?.replayed', 'JSON.stringify(replay.data.result) === JSON.stringify(result)', + 'sameSnapshot(proof.stockAfter, proof.stockAfterDepart)', 'sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)']) assert.ok(source.includes(value), value) + const helper = await read('fleet-trip-fulfillment.mjs') + for (const value of ['state.itemsBefore', 'JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)', + 'proof.loadingCommand.platformReplay = replay', 'JSON.stringify(replay.result) === JSON.stringify(confirmed.result)']) + assert.ok(helper.includes(value), value) }) test('return merge sources are actual consumed batches, not a synthetic MERGED identifier', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index 09d37da67..ee62dac6a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -1,193 +1,180 @@ -import test from 'node:test' import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { dispatchBusinessSnapshot, dispatchRouteSnapshot, dispatchRouteMatches, - selectDispatchRouteFixture, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve, basename } from 'node:path' +import { DISPATCH_BASE as BASE, dispatchEvidenceContext } from './grouped-dispatch-acceptance.mjs' +import { run, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs' -const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url) -const source = await readFile(fileURLToPath(moduleUrl), 'utf8') - -test('phase4 overseas dispatch module freezes only stable platform query and smart pre-arrange paths', () => { - for (const token of [ - "const BASE = '/api/platform/v1/last-leg/dispatches'", - "query(BASE, { page: 1, size: 20 })", - '`${BASE}/candidates`', '`${BASE}/drafts`', '`${BASE}/drafts/snapshot`', - '`${BASE}/draft-locks/lock`', '`${BASE}/pre-arrange`', '`${BASE}/draft-locks/release`', - '`${BASE}/${encodeURIComponent(dispatchNo)}`', - ]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) - for (const forbidden of ['/api/pda/', '/outbound', '/delivery', '/depart', '/cancel']) { - assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`) - } -}) - -test('module uses marker-owned completed PDA pallets and never borrows historical candidates', () => { - assert.match(source, /fixture\?\.marker !== context\.marker/) - assert.match(source, /fixture\?\.finalStatus !== 'COMPLETED'/) - assert.match(source, /fixture\.palletIds\.has\(id\(value\?\.warehousePalletId\)\)/) - assert.match(source, /id\(value\?\.overseasWarehouseId\) === id\(fixture\.overseasWarehouseId\)/) - assert.doesNotMatch(source, /candidates\[0\]/) -}) - -test('ordinary dispatch selects one current-run pallet per destination for multi-FBA delivery proof', () => { - assert.match(source, /const candidatesByDestination = new Map\(selectedCandidates.map/) - assert.match(source, /row\?\.fbaWarehouseCode \|\| row\?\.destinationWarehouseCode/) - assert.match(source, /selectedCandidates\.length >= 2/) - assert.match(source, /planPalletIds, fbaAppointmentNo: null/) - assert.match(source, /Number\(arranged\.data\?\.palletCount\) === planPalletIds\.length/) -}) - -function routeCandidates() { - return [['20', '120', 'ABE4', 2], ['30', '130', 'ABE8', 3], ['21', '121', 'ABE4', 4]].map(([planPalletId, warehousePalletId, fbaWarehouseCode, packageCount]) => ({ - planId: '10', planPalletId, warehousePalletId, fbaWarehouseCode, destinationWarehouseCode: fbaWarehouseCode, - overseasWarehouseId: '900', customerId: '100', inboundCode: `IN-${planPalletId}`, sourceStage: 'RECEIVED', destinationType: 'FBA', packageCount, - })) -} -function responseFor(candidates) { - return { id: '1', dispatchNo: 'DS-TEST', overseasWarehouseId: '900', status: 'PENDING_DEPARTURE', - route: [...new Set(candidates.map(row => row.fbaWarehouseCode))].join(','), - items: candidates.map(row => ({ ...row, id: '8' + row.planPalletId })), - itemCount: candidates.length, palletCount: candidates.length, - packageCount: candidates.reduce((sum, row) => sum + row.packageCount, 0), pickedPackageCount: 0, shippedPackageCount: 0 } -} - -test('C52 fixture needs two destinations and two distinct physical pallets at one destination', () => { - const rows = routeCandidates() - assert.equal(selectDispatchRouteFixture(rows.slice(0, 2)), null) - assert.equal(selectDispatchRouteFixture([rows[0], rows[2]]), null) - assert.equal(selectDispatchRouteFixture([rows[0], rows[1], { ...rows[2], warehousePalletId: rows[0].warehousePalletId }]), null) - const result = selectDispatchRouteFixture(rows) - assert.deepEqual(result.selected.map(row => row.planPalletId), ['20', '30']) - assert.equal(result.duplicate.planPalletId, '21') - assert.match(source, /generatedPalletsByInbound/) - assert.match(source, /id\(value\?\.customerId\) === id\(fixture.customerId\)/) - assert.match(source, /value\?\.destinationType === 'FBA'/) -}) - -test('C52 independently derives route from actual rows and rejects fabricated destinations, sources, totals and duplicate route codes', () => { - const rows = routeCandidates(), value = responseFor(rows) - assert.equal(dispatchRouteMatches(value, rows), true) - for (const route of ['ABE4', 'ABE4,ABE8,ABE9', 'ABE4,ABE8,ABE4', 'ABE4,ABE8,', '']) { - assert.equal(dispatchRouteMatches({ ...value, route }, rows), false) - } - for (const patch of [{ itemCount: 9 }, { packageCount: 10 }, { overseasWarehouseId: '901' }, { status: 'PICKING' }]) { - assert.equal(dispatchRouteMatches({ ...value, ...patch }, rows), false) - } - for (const patch of [{ inboundCode: 'FOREIGN' }, { warehousePalletId: '999' }, { fbaWarehouseCode: 'ABE9' }, { planPalletId: '999' }]) { - assert.equal(dispatchRouteMatches({ ...value, items: [{ ...value.items[0], ...patch }, ...value.items.slice(1)] }, rows), false) - } - assert.notDeepEqual(dispatchRouteSnapshot(value), dispatchRouteSnapshot({ ...value, items: [{ ...value.items[0], id: '999' }, ...value.items.slice(1)] })) - assert.notEqual(dispatchBusinessSnapshot(value), dispatchBusinessSnapshot({ ...value, route: 'ABE9' })) -}) - -function lifecycleHarness(fault = '') { - const rows = routeCandidates(), selected = rows.slice(0, 2) - let state = responseFor(selected), nextItemId = 900, preArrangeCount = 0 - const calls = [], checks = [] - const base = '/api/platform/v1/last-leg/dispatches' - const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'REQ', traceId: 'TRACE' }) - const error = code => ({ http: 200, body: { code }, requestId: 'REQ', traceId: 'TRACE' }) - const context = { marker: 'CURRENT', createdResources: { phase4OverseasDispatch: {} }, - apiSuccess: (_module, _name, response) => response.body.data, - assert: (_module, name, _response, passed) => checks.push({ name, passed }), - request: async (method, path, body) => { - calls.push({ method, path, body }) - if (path.includes('/candidates')) return ok(rows.filter(row => !state.items.some(item => item.planPalletId === row.planPalletId))) - if (path.endsWith('/draft-locks/lock')) return ok(null) - if (method === 'GET') return ok(fault === 'wrong-route' ? { ...state, route: 'ABE9' } : state) - if (method === 'DELETE') { - const itemId = path.split('/').at(-1), remaining = state.items.filter(row => row.id !== itemId) - if (remaining.length === state.items.length) { - if (fault === 'rejected-delete-mutates') state.route = 'ABE9' - return error('A0404') +const clone = value => structuredClone(value) +const rows = () => [1, 2, 3].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) }, + sourceKey: `PLAN:2${n}`, sourceVersion: `${n}`.repeat(64), customerId: '55', warehouseId: '44', inboundCode: `IN-${n}`, palletNo: `P-${n}`, + destination: { key: `FBA:A${n === 3 ? 1 : n}`, type: 'FBA', warehouseCode: `A${n === 3 ? 1 : n}` }, availablePackages: n + 1, appointmentBatches: [], blockReasons: [] })) +// All state here is offline test data. This server rejects missing versions and preserves original-key results. +function server(fault = '', initial = false) { + const candidates = rows(), drafts = new Map(), orders = new Map(), ledger = new Map(), calls = [], checks = [] + let serial = 0, containerId = 60, itemId = 70 + const counts = o => ({ itemCount: o.items.length, palletCount: o.items.length, packageCount: o.items.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: 0, shippedPackageCount: 0 }) + const item = c => ({ dispatchItemId: String(++itemId), sourceType: c.selection.sourceType, sourceKey: c.sourceKey, sourceVersion: c.sourceVersion, customerId: c.customerId, + planPalletId: c.selection.planPalletId, warehousePalletId: c.selection.warehousePalletId, inboundCode: c.inboundCode, destination: clone(c.destination), scheduledPackages: c.availablePackages, pickedPackages: 0, remove: { allowed: true } }) + const syncRoute = o => { o.keys = [...new Set([...(fault === 'append-only-route' ? o.keys || [] : []), ...o.items.map(i => i.destination.key)])] } + const create = cargo => { const o = { dispatchNo: `DS-${orders.size + 1}`, recordVersion: initial && orders.size === 0 ? 0 : 1, items: cargo.map(s => item(candidates.find(c => c.selection.planPalletId === s.planPalletId))) }; syncRoute(o); orders.set(o.dispatchNo, o); return o } + const command = (o, action) => ({ dispatchNo: o.dispatchNo, action, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o), replayed: false }) + const view = o => ({ dispatchNo: o.dispatchNo, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: o.recordVersion, counts: counts(o), + dispatchInfo: { route: [...new Set(o.items.map(i => i.destination.warehouseCode))].join(',') }, + orderDetails: o.items.map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) + const stops = o => [{ index: 0, name: 'Actual Origin', address: 'Warehouse 44', destinationKey: 'ORIGIN:44' }, ...o.keys.map((key, n) => ({ index: n + 1, name: key, destinationKey: key }))] + const response = (data, code = '200') => ({ http: 200, body: { code, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: 'CURRENT', createdResources: { phase4OverseasPda: { marker: 'CURRENT', finalStatus: 'COMPLETED', customerId: '55', overseasWarehouseId: '44', palletIds: ['31', '32', '33'] } }, + checkpoint: async () => {}, // Explicit offline double; full-run test below removes it and verifies actual disk writes. + assert: (_module, label, _response, passed) => checks.push({ label, passed }), + async request(method, raw, body) { + calls.push({ method, path: raw, body: clone(body) }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), write = method !== 'GET' + const key = `${method}:${path}:${body?.idempotencyKey}` + if (write) { + assert.ok(body?.idempotencyKey, raw) + if (ledger.has(key)) { + const old = ledger.get(key); assert.equal(JSON.stringify(body), old.body, 'replay must use original body and version') + if (fault === 'replay-row-drift' && path.endsWith('/items')) orders.get('DS-1').items[0].dispatchItemId = '9999' + const data = clone(old.data); if (data?.action || data?.vehicles) data.replayed = true; return response(data) } - state = { ...responseFor(remaining), items: remaining } - return ok(state) } - if (path === `${base}/pre-arrange`) { - if (new Set(body.planPalletIds).size !== body.planPalletIds.length) return error('A0400') - if (body.draftToken !== 'DRAFT') return error('A0409') - preArrangeCount++ - const picked = [...body.planPalletIds].sort().map(palletId => rows.find(row => row.planPalletId === palletId)) - const items = picked.map(row => state.items.find(item => item.planPalletId === row.planPalletId) || { ...row, id: String(nextItemId++) }) - state = { ...responseFor(picked), items } - if (fault === 'replay-id-drift' && preArrangeCount === 2) state.items[0].id = String(nextItemId++) - return ok(state) + const remember = data => { ledger.set(key, { body: JSON.stringify(body), data: clone(data) }); return response(data) } + const draft = drafts.get(body?.draftToken) + if (method === 'POST' && path === `${BASE}/drafts`) { const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', sources: [], containers: [] }; drafts.set(d.draftToken, d); return remember(d) } + if (method === 'GET' && path === `${BASE}/drafts`) return response([...drafts.values()].filter(d => d.status === 'ACTIVE')) + if (path === `${BASE}/candidates`) { + assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED') + const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey))) + for (const d of drafts.values()) if (d.status === 'ACTIVE' && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey) + return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' }) } - throw new Error(`unexpected request ${method} ${path}`) + if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: body.capacity, sourceKeys: [] }); draft.recordVersion++; return remember(draft) } + const deleteContainer = path.match(/\/drafts\/([^/]+)\/containers\/(\d+)$/) + if (deleteContainer) { const d = drafts.get(deleteContainer[1]); assert.equal(body.recordVersion, d.recordVersion); const c = d.containers.find(c => c.draftContainerId === deleteContainer[2]); if (c.sourceKeys.length) return response(null, 'A0409'); d.containers = d.containers.filter(c => c.draftContainerId !== deleteContainer[2]); d.recordVersion++; return remember(d) } + if (path === `${BASE}/draft-locks/lock` || path === `${BASE}/draft-locks/unlock`) { + assert.equal(body.recordVersion, draft.recordVersion) + const c = candidates.find(c => c.selection.planPalletId === body.source.planPalletId), container = draft.containers.find(g => g.draftContainerId === body.draftContainerId) + assert.deepEqual(body.source, c.selection) + if (path.endsWith('/lock')) { + if ([...orders.values()].some(o => o.items.some(i => i.sourceKey === c.sourceKey))) return response(null, 'A0409') + draft.sources.push(clone(c)); container.sourceKeys.push(c.sourceKey) + } else { draft.sources = draft.sources.filter(s => s.sourceKey !== c.sourceKey); container.sourceKeys = container.sourceKeys.filter(k => k !== c.sourceKey) } + draft.recordVersion++; return remember(draft) + } + if (path === `${BASE}/draft-locks/release`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(draft.sources.length, 0); draft.status = 'RELEASED'; draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/drafts/snapshot`) { assert.equal(body.recordVersion, draft.recordVersion); assert.ok(Array.isArray(body.planIds)); assert.ok(Array.isArray(body.containers)); draft.containers = clone(body.containers); draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/drafts/pre-arrange`) { + assert.equal(body.recordVersion, draft.recordVersion) + if (!body.groups.every(g => JSON.stringify(g.dispatch.cargo.map(s => `PLAN:${s.planPalletId}`).sort()) === JSON.stringify([...draft.containers.find(c => c.draftContainerId === g.draftContainerId).sourceKeys].sort()))) return response(null, 'A0409') + const vehicles = body.groups.map(g => ({ draftContainerId: g.draftContainerId, dispatch: command(create(g.dispatch.cargo), 'CREATE') })); draft.status = 'CONVERTED' + return remember({ draftToken: draft.draftToken, vehicles, replayed: false }) + } + if (path === BASE && method === 'POST') { assert.equal(body.vehicle, null); assert.equal(body.driver, null); return remember(command(create(body.cargo), 'CREATE')) } + if (path === BASE && method === 'GET') return response({ records: [...orders.values()].filter(o => o.dispatchNo === params.get('dispatchNo')).map(o => ({ ...command(o, 'READ'), warehouseId: '44' })) }) + const match = path.slice(BASE.length).match(/^\/([^/]+)(?:\/(items|route)(?:\/([^/]+)\/remove)?)?$/), o = orders.get(match?.[1]); assert.ok(o, `${method} ${raw}`) + if (write) { + assert.equal(body.recordVersion, o.recordVersion, 'explicit server version, including zero') + if (match[2] === 'route') { assert.equal(method, 'PUT'); assert.deepEqual([...body.destinationKeys].sort(), [...o.keys].sort()); o.keys = clone(body.destinationKeys); o.recordVersion++; return remember(command(o, 'UPDATE_ROUTE')) } + if (match[3]) { + if (!o.items.some(i => i.dispatchItemId === match[3])) { if (fault === 'rejected-remove-mutates') o.recordVersion++; return response(null, 'A0404') } + o.items = o.items.filter(i => i.dispatchItemId !== match[3]); o.recordVersion++; syncRoute(o); return remember(command(o, 'REMOVE_CARGO')) + } + assert.equal(method, 'POST'); assert.equal(match[2], 'items') + if (new Set(body.cargo.map(s => s.planPalletId)).size !== body.cargo.length) return response(null, 'A0400') + o.items.push(...body.cargo.map(s => item(candidates.find(c => c.selection.planPalletId === s.planPalletId)))); o.recordVersion++; syncRoute(o) + if (fault === 'unknown-add') return { http: 0, body: null } + if (fault === 'route-drift') o.keys = ['FBA:FOREIGN'] + return remember(command(o, 'ADD_CARGO')) + } + if (match[2] === 'items') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: o.items }) + if (match[2] === 'route') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, stops: stops(o), edit: { allowed: true } }) + return response(view(o)) } } - return { context, calls, checks, options: { dispatchNo: 'DS-TEST', candidates: selected, duplicate: rows[2], draftToken: 'DRAFT', draftContainerId: '50' } } + if (initial) { create(candidates.slice(0, 2).map(c => c.selection)); context.createdResources.phase4OverseasDispatch = {} } + return { context, candidates, calls, checks, orders, bundle: () => { const o = orders.get('DS-1'); return { data: view(o), warehouseId: '44', items: { items: clone(o.items) }, route: { stops: stops(o) } } } } } -test('C52 HTTP orchestration adds duplicate destination, removes last destination, restores and freezes exact replay/rejection facts', async () => { - const h = lifecycleHarness() - await verifyDispatchRouteLifecycle(h.context, h.options) - assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED') - assert.ok(h.checks.length >= 20 && h.checks.every(row => row.passed)) - for (const label of ['同目的地第二托增加箱托数但路线稳定去重', '最后一托移除后路线删除该目的地', '重新加入后恢复原目的地集合', '重复托盘ID拒绝A0400', '其他草稿抢占原排车拒绝A0409']) { - assert.ok(h.checks.some(row => row.name.includes(label))) - } - assert.ok(h.calls.filter(row => row.path.endsWith('/pre-arrange')).every(row => !Object.hasOwn(row.body, 'route'))) - assert.equal(h.calls.filter(row => row.method === 'DELETE').length, 3) - assert.deepEqual(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.stages.at(-1).snapshot.items.map(row => row.planPalletId), ['20', '30']) +test('native route fixture uses real physical pallets and requires two destinations plus a repeated destination', () => { + const c = rows(), selected = selectDispatchRouteFixture(c) + assert.deepEqual(selected.selected.map(c => c.sourceKey), ['PLAN:21', 'PLAN:22']); assert.equal(selected.duplicate.sourceKey, 'PLAN:23') + assert.equal(selectDispatchRouteFixture(c.slice(0, 2)), null) + c[2].selection.warehousePalletId = c[0].selection.warehousePalletId; assert.equal(selectDispatchRouteFixture(c), null) }) -test('C52 stops on wrong route, replay row identity drift or side effects after rejected removal', async () => { - for (const fault of ['wrong-route', 'replay-id-drift', 'rejected-delete-mutates']) { - const h = lifecycleHarness(fault) - await assert.rejects(verifyDispatchRouteLifecycle(h.context, h.options), /C52/) - assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'UNPROVEN') - assert.ok(h.checks.some(row => !row.passed)) +test('native route proof rejects drift in actual identity, physical scope, counts and destination keys', () => { + const h = server('', true), original = h.bundle(), expected = h.candidates.slice(0, 2) + assert.equal(dispatchRouteMatches(original, expected), true) + for (const mutate of [b => { b.warehouseId = '999' }, b => { b.items.items[0].customerId = '999' }, b => { b.items.items[0].warehousePalletId = '999' }, b => { b.items.items[0].sourceVersion = '0'.repeat(64) }, b => { b.data.counts.packageCount++ }, b => { b.route.stops[1].destinationKey = 'FBA:OTHER' }, b => { b.data.dispatchInfo.route = 'A1,A1,A2' }, b => { b.items.items[1].dispatchItemId = b.items.items[0].dispatchItemId }, b => { b.data.orderDetails[0].customerId = '999' }, b => { b.data.orderDetails[0].sourceKey = 'PLAN:999' }, b => { b.data.orderDetails[0].destinationFbaWarehouse = 'OTHER' }]) { const bad = clone(original); mutate(bad); assert.equal(dispatchRouteMatches(bad, expected), false) } + const changed = clone(original); changed.items.items[0].dispatchItemId = '9999'; assert.notEqual(dispatchBusinessSnapshot(changed), dispatchBusinessSnapshot(original)) +}) + +test('C52 uses native versioned cargo commands, immutable replay, actual readback and route keys', async () => { + const h = server('', true), current = await verifyDispatchRouteLifecycle(h.context, { dispatchNo: 'DS-1', candidates: h.candidates.slice(0, 2), duplicate: h.candidates[2] }) + assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED'); assert.ok(h.checks.every(c => c.passed)) + assert.equal(current.data.counts.packageCount, 5); assert.equal(current.data.recordVersion, 6) + const firstAdd = h.calls.find(c => c.method === 'POST' && c.path.endsWith('/items')); assert.equal(firstAdd.body.recordVersion, 0) + assert.deepEqual(h.calls.filter(c => c.method === 'PUT').map(c => c.body.destinationKeys), [['FBA:A2', 'FBA:A1'], ['FBA:A2', 'FBA:A1'], ['FBA:A1', 'FBA:A2'], ['FBA:A1', 'FBA:A2']]) + assert.equal(h.calls.some(c => c.method === 'DELETE'), false) +}) + +test('C52 rejects replay row drift, rejection side effects, wrong route and unknown writes without cleanup', async () => { + for (const fault of ['replay-row-drift', 'rejected-remove-mutates', 'route-drift', 'unknown-add']) { + const h = server(fault, true) + await assert.rejects(verifyDispatchRouteLifecycle(h.context, { dispatchNo: 'DS-1', candidates: h.candidates.slice(0, 2), duplicate: h.candidates[2] })) + assert.notEqual(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED') + assert.equal(h.calls.some(c => c.method === 'PUT' || c.method === 'DELETE'), false) + if (fault === 'unknown-add') { assert.equal(h.context.createdResources.dispatchWriteBoundary.outcomeUnknown, true); assert.equal(h.calls.filter(c => c.method !== 'GET').length, 1); assert.equal(h.orders.get('DS-1').items.length, 3) } } }) -test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => { - assert.match(source, /相同草稿快照重复保存/) - assert.match(source, /同草稿重复锁定托盘幂等成功/) - assert.match(source, /其他草稿锁定同一托盘返回A0409/) - assert.match(source, /出库前同草稿重复预排复用原排车且业务快照不漂移/) - assert.match(source, /重复释放草稿幂等成功/) - assert.match(source, /排车详情按记录自身客户范围加载托盘明细/) - assert.match(source, /response\?\.http === 409.*codeOf\(response\) === 'A0409'/s) +test('phase4 missing current marker or real multi-destination fixture is explicit NOT_RUN before writes', async () => { + const h = server(); h.context.createdResources.phase4OverseasPda.marker = 'OLD'; assert.equal((await run(h.context)).status, 'NOT_RUN'); assert.equal(h.calls.length, 0) + const other = server(); other.candidates.pop(); assert.equal((await run(other.context)).status, 'NOT_RUN'); assert.equal(other.calls.filter(c => c.method !== 'GET').length, 0) + const ambiguous = server(); ambiguous.candidates[2].appointmentBatches = [{}, {}]; assert.equal((await run(ambiguous.context)).status, 'NOT_RUN'); assert.equal(ambiguous.calls.filter(c => c.method !== 'GET').length, 0) }) -test('module does not invent optimistic version or idempotency headers absent from DispatchRequests', () => { - assert.doesNotMatch(source, /expectedVersion|recordVersion|Idempotency-Key/) - assert.match(source, /PENDING_DEPARTURE/) - assert.match(source, /data == null/) -}) - -test('module records the marker-owned dispatch without attempting excluded cleanup actions', () => { - assert.match(source, /createdResources\.phase4OverseasDispatch/) - assert.match(source, /dispatchNo = dispatchNo/) - assert.match(source, /finalStatus = arranged\.data\?\.status/) - assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/) -}) - -test('module prefers marker-owned relabel pallets and proves only new FBA carton marks are frozen', () => { - assert.match(source, /phase4OverseasRelabel/) - assert.match(source, /sourceStage === 'RELABEL'/) - assert.match(source, /relabel\.targetPalletIds\.includes/) - assert.match(source, /relabelCartonMarks/) - assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/) - assert.match(source, /relabelNo: relabel\.relabelNo/) -}) - - test('relabel settlement follows pre-arrange and does not replace the ordinary dispatch lifecycle', () => { - assert.match(source, /await runRelabelDispatch\(context, relabel\)[\s\S]*if \(relabel.dispatchNo\) await settleRelabelFulfillment\(context, relabel\)/) - const branch = source.slice(source.indexOf('const relabel = context.createdResources'), source.indexOf('const fixture = markerFixture(context)', source.indexOf('const relabel = context.createdResources'))) - assert.doesNotMatch(branch, /\breturn\b/) - assert.match(source, /Object.assign\(relabel, \{/) -}) - -test('dispatch replay snapshot ignores regenerated item ids but detects business drift', () => { - const a = { id: '1', dispatchNo: 'DS-1', status: 'PENDING_DEPARTURE', packageCount: 2, - palletCount: 1, items: [{ id: '10', planPalletId: '20', warehousePalletId: '30', packageCount: 2 }] } - const replay = { ...a, items: [{ ...a.items[0], id: '11' }] } - assert.equal(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(replay)) - for (const changed of [{ ...replay, id: '2' }, { ...replay, packageCount: 4 }, - { ...replay, items: [{ ...a.items[0], warehousePalletId: '31' }] }]) { - assert.notEqual(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(changed)) +test('complete offline phase4 hands off the actual native Detail and current source keys at BEFORE_LOADING', async () => { + const directory = await mkdtemp(join(tmpdir(), 'phase4-dispatch-contract-')) + try { + const h = server(); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') } + h.context.createdResources.dispatchWriteBoundary = { marker: 'CURRENT', stopped: false, password: 'PRIVATE_PASSWORD', token: 'PRIVATE_LOGIN_TOKEN' } + const originalRequest = h.context.request + h.context.request = async (method, path, body) => { + if (method !== 'GET') { + const evidence = JSON.parse(await readFile(join(directory, 'phase4-dispatch-checkpoints', 'CURRENT.json'), 'utf8')) + const intents = [...(evidence.phase4OverseasDispatch.requests || []), ...(evidence.groupedDispatch?.requests || []), ...(evidence.phase4OverseasDispatch.routeLifecycle?.requests || [])] + assert.ok(intents.some(r => r.status === 'SENDING' && r.method === method && r.path === path && JSON.stringify(r.body) === JSON.stringify(body)), 'original write must already exist on disk') + } + return originalRequest(method, path, body) + } + const proof = await run(h.context) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.finalStatus, 'ACTIVE'); assert.equal(proof.stage, 'BEFORE_LOADING'); assert.ok(h.checks.every(c => c.passed)) + assert.deepEqual(proof.sourceKeys, ['PLAN:21', 'PLAN:22']); assert.deepEqual(proof.destinationWarehouseCodes, ['A1', 'A2']); assert.equal(proof.warehouseId, '44') + const actual = await h.context.request('GET', `${BASE}/${proof.dispatchNo}`); assert.deepEqual(proof.data, actual.body.data) + assert.equal(Object.hasOwn(proof.data, 'items'), false); assert.equal(Object.hasOwn(proof.data, 'route'), false); assert.equal(Object.hasOwn(proof, 'dispatchId'), false) + assert.equal(Object.hasOwn(proof, 'loadingMeasurements'), false); assert.equal(proof.loadingEvidenceStatus, 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS') + assert.equal(h.calls.some(c => /\/review|\/pick|\/scan|\/loading|\/depart|\/transfer-delivery/.test(c.path)), false) + assert.equal(h.calls.some(c => c.path === `${BASE}/pre-arrange` || c.body?.containersJson), false) + const text = await readFile(proof.checkpointPath, 'utf8'), journal = JSON.parse(text) + assert.equal(journal.phase4OverseasDispatch.status, 'VERIFIED'); assert.ok(journal.phase4OverseasDispatch.draftToken) + assert.equal(text.includes('PRIVATE_PASSWORD'), false); assert.equal(text.includes('PRIVATE_LOGIN_TOKEN'), false) + await assert.rejects(dispatchEvidenceContext({ marker: 'CURRENT', options: h.context.options, createdResources: { phase4OverseasDispatch: {} } }), /已存在本轮排车检查点/) + const stale = server('append-only-route'); await assert.rejects(run(stale.context), /最后实物移出后真实数量归零/) + assert.equal(stale.context.createdResources.phase4OverseasDispatch.status, 'UNPROVEN'); assert.equal(stale.orders.size, 1) + assert.equal(stale.calls.some(c => c.path.endsWith('/drafts')), false) + } finally { + assert.ok(basename(directory).startsWith('phase4-dispatch-contract-')); assert.equal(resolve(directory, '..'), resolve(tmpdir())) + await rm(directory, { recursive: true, force: true }) } - assert.doesNotMatch(source, /item\?\.cartonMarks/) +}) + +test('relabel creation cannot prematurely settle fulfillment or fabricate loading measurements', () => { + const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8') + assert.equal(source.includes('settleRelabelFulfillment'), false) + assert.equal(source.includes('vehicleAppointmentStatus'), false) + assert.equal(source.includes('loadingMeasurements:'), false) + assert.ok(source.includes('fixture.targetPalletIds')); assert.ok(source.includes('READY_FOR_OUTBOUND')) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 22b0281e2..a7804bb7b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -1,440 +1,197 @@ -import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs' - -import { verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs' +import { DISPATCH_BASE as BASE, id, canonical, version, success, DispatchNotRun, sourceSelection, createBody, markerCandidates, + notRun, dispatchIo, dispatchEvidenceContext, versionAfter, query, candidatePage, replay, readDispatch, dispatchMatches, newDraft, activeDraft, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs' const MODULE = 'phase4-overseas-dispatch' -const SCENARIO = 'phase4-overseas-dispatch-lifecycle' -const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const BASE = '/api/platform/v1/last-leg/dispatches' - -function id(value) { return value == null ? '' : String(value) } -function records(value) { return Array.isArray(value?.records) ? value.records : [] } -function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } -function success(response) { return response?.http === 200 && codeOf(response) === '200' } -export function dispatchBusinessSnapshot(value) { - return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status, - route: value?.route, packageCount: value?.packageCount, palletCount: value?.palletCount, - items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId), - warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage, - packageCount: item.packageCount, inboundCode: item.inboundCode, - destinationWarehouseCode: item.destinationWarehouseCode, fbaWarehouseCode: item.fbaWarehouseCode })) - .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), - }) -} -function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } -function trace(response) { - return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` -} -function query(path, values) { - const params = new URLSearchParams() - for (const [key, value] of Object.entries(values || {})) { - if (value !== null && value !== undefined && String(value) !== '') params.set(key, String(value)) - } - const encoded = params.toString() - return encoded ? `${path}?${encoded}` : path -} - -async function api(context, method, name, path, body) { - const response = await context.request(method, path, body) - const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) - return { response, data } -} - -function blocker(context, name, evidence, response = null) { - context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) - context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) -} - -function markerFixture(context) { - const fixture = context.createdResources?.phase4OverseasPda - if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED') return null - const palletIds = new Set([...(fixture.palletIds || []), - ...Object.values(fixture.generatedPalletsByInbound || {}).flat().filter(row => - id(row.warehouseId) === id(fixture.overseasWarehouseId)).map(row => row.palletId)].map(id).filter(Boolean)) - if (!fixture.overseasWarehouseId || palletIds.size === 0) return null - return { ...fixture, palletIds } -} - -const destination = row => String(row?.fbaWarehouseCode || row?.destinationWarehouseCode || '').trim() +const destination = candidate => candidate?.destination?.warehouseCode +const same = (a, b) => canonical(a) === canonical(b) const sorted = values => [...values].sort() -const same = (left, right) => JSON.stringify(left) === JSON.stringify(right) -// Item IDs are included here: editable pre-arrange retains existing rows, and rejected -// commands must preserve the exact assigned facts, not merely their counts. -export function dispatchRouteSnapshot(value) { - return { id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status, - route: value?.route ?? '', warehouseId: id(value?.overseasWarehouseId), - itemCount: value?.itemCount, palletCount: value?.palletCount, packageCount: value?.packageCount, - pickedPackageCount: value?.pickedPackageCount, shippedPackageCount: value?.shippedPackageCount, - items: (value?.items || []).map(row => ({ id: id(row.id), planPalletId: id(row.planPalletId), - warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo, - warehouseId: id(row.overseasWarehouseId), inboundCode: row.inboundCode, - orderNo: row.orderNo, orderRefs: row.orderRefs, sourceStage: row.sourceStage, - destinationWarehouseCode: row.destinationWarehouseCode, fbaWarehouseCode: row.fbaWarehouseCode, - packageCount: row.packageCount })).sort((a, b) => a.planPalletId.localeCompare(b.planPalletId)) } +// 读取对象保持三个原生响应独立,不给Detail补回旧items/route字段。 +export function dispatchRouteSnapshot(bundle) { + return { dispatchNo: bundle?.data?.dispatchNo, status: bundle?.data?.status, stage: bundle?.data?.stage, + recordVersion: bundle?.data?.recordVersion, warehouseId: bundle?.warehouseId, counts: bundle?.data?.counts, + route: bundle?.data?.dispatchInfo?.route, stops: bundle?.route?.stops, + items: bundle?.items?.items?.map(i => ({ dispatchItemId: i.dispatchItemId, sourceKey: i.sourceKey, sourceVersion: i.sourceVersion, + customerId: i.customerId, planPalletId: i.planPalletId, warehousePalletId: i.warehousePalletId, + destination: i.destination, inboundCode: i.inboundCode, scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages })).sort((a, b) => a.sourceKey.localeCompare(b.sourceKey)) } } - -export function dispatchRouteMatches(value, expectedCandidates) { - if (!Array.isArray(value?.items) || !Array.isArray(expectedCandidates)) return false - const items = value.items, route = String(value.route || '').trim() - const routeParts = route ? route.split(',').map(part => part.trim()) : [] - const actualDestinations = sorted(new Set(items.map(destination))) - return value.status === 'PENDING_DEPARTURE' - && Number(value.itemCount) === items.length && Number(value.palletCount) === items.length - && Number(value.packageCount) === items.reduce((total, row) => total + Number(row.packageCount), 0) - && items.length === expectedCandidates.length - && new Set(items.map(row => id(row.planPalletId))).size === items.length - && new Set(items.map(row => id(row.warehousePalletId))).size === items.length - && items.every(row => /^[1-9][0-9]*$/.test(id(row.id)) && destination(row) - && expectedCandidates.some(expected => id(expected.planPalletId) === id(row.planPalletId) - && id(expected.warehousePalletId) === id(row.warehousePalletId) - && id(expected.overseasWarehouseId) === id(row.overseasWarehouseId) - && id(value.overseasWarehouseId) === id(row.overseasWarehouseId) - && expected.sourceStage === row.sourceStage && expected.inboundCode === row.inboundCode - && Number(expected.packageCount) === Number(row.packageCount) && Number(row.packageCount) > 0 - && destination(expected) === destination(row))) - && routeParts.every(Boolean) && routeParts.length === new Set(routeParts).size - && same(sorted(routeParts), actualDestinations) +export function dispatchBusinessSnapshot(bundle) { return canonical(dispatchRouteSnapshot(bundle)) } +export function dispatchRouteMatches(bundle, candidates) { + if (!dispatchMatches(bundle, candidates) || !Array.isArray(bundle.route?.stops) || bundle.route.stops[0]?.index !== 0 + || bundle.route.stops[0].destinationKey !== `ORIGIN:${bundle.warehouseId}`) return false + const codes = String(bundle.data.dispatchInfo?.route || '').split(',').map(s => s.trim()).filter(Boolean) + const expected = sorted(new Set(candidates.map(destination))) + const keys = bundle.route.stops.slice(1).map(s => s.destinationKey) + return expected.every(Boolean) && codes.length === new Set(codes).size && same(sorted(codes), expected) + && keys.length === new Set(keys).size && same(sorted(keys), sorted(new Set(candidates.map(c => c.destination.key)))) } - export function selectDispatchRouteFixture(candidates) { - const unique = [...new Map(candidates.map(row => [id(row.warehousePalletId), row])).values()] const groups = new Map() - for (const row of unique) { - const code = destination(row) - if (code) groups.set(code, [...(groups.get(code) || []), row]) + for (const candidate of new Map(candidates.map(c => [id(c.selection?.warehousePalletId), c])).values()) { + const code = destination(candidate); if (code) groups.set(code, [...(groups.get(code) || []), candidate]) } const repeated = [...groups.values()].find(rows => rows.length >= 2) - if (groups.size < 2 || !repeated) return null - return { selected: [...groups.values()].map(rows => rows[0]), duplicate: repeated[1] } + const other = [...groups.values()].find(rows => rows !== repeated) + return groups.size >= 2 && repeated ? { selected: [repeated[0], other[0]], duplicate: repeated[1] } : null } - -export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate, draftToken, draftContainerId }) { - const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = { - marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [], - } - const detailPath = `${BASE}/${encodeURIComponent(dispatchNo)}` - let identity = null - const check = (name, response, passed, evidence) => { - context.assert(MODULE, `C52 ${name}`, response, passed, evidence, OPTIONS) - if (!passed) throw new Error(`C52 ${name}`) - } - const read = async (name, expected) => { - const current = await api(context, 'GET', `C52 ${name}回查实际分配明细`, detailPath) - const snapshot = dispatchRouteSnapshot(current.data) - const passed = success(current.response) && current.data?.dispatchNo === dispatchNo - && (!identity || snapshot.id === identity) && dispatchRouteMatches(current.data, expected) - proof.stages.push({ name, passed, snapshot, expectedPlanPalletIds: expected.map(row => id(row.planPalletId)), - requestId: current.response?.requestId, traceId: current.response?.traceId }) - check(`${name}实际明细独立推导路线及本轮来源核平`, current.response, passed, JSON.stringify(snapshot)) - identity ||= snapshot.id - return current.data - } - const lock = async row => { - const result = await api(context, 'POST', 'C52 新增本轮托盘到原草稿锁', `${BASE}/draft-locks/lock`, - { planPalletId: id(row.planPalletId), draftToken, draftContainerId }) - check('新增草稿锁成功', result.response, success(result.response), trace(result.response)) - } - const arrange = async (name, selected) => { - const body = { planPalletIds: selected.map(row => id(row.planPalletId)), draftToken } - const result = await api(context, 'POST', `C52 ${name}`, `${BASE}/pre-arrange`, body) - check(`${name}复用原排车`, result.response, success(result.response) && result.data?.dispatchNo === dispatchNo, trace(result.response)) - const current = await read(name, selected) - const replay = await api(context, 'POST', `C52 ${name}同草稿同载荷重放`, `${BASE}/pre-arrange`, body) - const after = await read(`${name}重放后`, selected) - check(`${name}重放路线及完整明细零漂移`, replay.response, success(replay.response) - && same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(replay.data)) - && same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(replay.response)) - return after - } - const rejectUnchanged = async (name, method, path, body, expectedCode, current, selected) => { - const result = await context.request(method, path, body) - // These business errors use the current gateway envelope and must retain traces. - check(name, result, result?.http === 200 && codeOf(result) === expectedCode - && Boolean(result.requestId) && Boolean(result.traceId), trace(result)) - const after = await read(`${name}后`, selected) - check(`${name}路线数量及明细零漂移`, result, - same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(result)) - } - const initial = await read('初始多目的地', candidates) - const expanded = [...candidates, duplicate] - await lock(duplicate) - const added = await arrange('新增同目的地第二托', expanded) - check('同目的地第二托增加箱托数但路线稳定去重', null, - initial.route === added.route && added.palletCount === initial.palletCount + 1, - `before=${initial.route}, after=${added.route}`) - const extraItem = added.items.find(row => id(row.planPalletId) === id(duplicate.planPalletId)) - const removeExtra = await api(context, 'DELETE', 'C52 移除同目的地第二托', `${detailPath}/items/${id(extraItem.id)}`) - const dedup = await read('移除同目的地第二托', candidates) - check('同目的地仍有托盘时路线不消失', removeExtra.response, success(removeExtra.response) && dedup.route === initial.route, trace(removeExtra.response)) - const removedCandidate = candidates.find(row => destination(row) !== destination(duplicate)) - const removedItem = dedup.items.find(row => id(row.planPalletId) === id(removedCandidate.planPalletId)) - const reducedCandidates = candidates.filter(row => row !== removedCandidate) - const removePath = `${detailPath}/items/${id(removedItem.id)}` - const removed = await api(context, 'DELETE', 'C52 移除某目的地最后一托', removePath) - check('移除最后一托请求成功', removed.response, success(removed.response), trace(removed.response)) - const reduced = await read('移除目的地最后一托', reducedCandidates) - check('最后一托移除后路线删除该目的地', null, - !reduced.route.split(',').map(part => part.trim()).includes(destination(removedCandidate)), `route=${reduced.route}`) - await rejectUnchanged('重复移除已删除明细A0404', 'DELETE', removePath, undefined, 'A0404', reduced, reducedCandidates) - await lock(removedCandidate) - const restored = await arrange('重新加入已移除目的地', candidates) - check('重新加入后恢复原目的地集合', null, - same(sorted(restored.route.split(',')), sorted(initial.route.split(','))), `route=${restored.route}`) - const body = { planPalletIds: candidates.map(row => id(row.planPalletId)), draftToken } - await rejectUnchanged('重复托盘ID拒绝A0400', 'POST', `${BASE}/pre-arrange`, - { ...body, planPalletIds: [...body.planPalletIds, body.planPalletIds[0]] }, 'A0400', restored, candidates) - await rejectUnchanged('其他草稿抢占原排车拒绝A0409', 'POST', `${BASE}/pre-arrange`, - { ...body, draftToken: `${context.marker}-C52-FOREIGN`.slice(0, 64) }, 'A0409', restored, candidates) - const freed = await api(context, 'GET', 'C52 移除重复目的地托盘后候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - check('额外托盘正式锁已释放', freed.response, success(freed.response) && Array.isArray(freed.data) - && freed.data.some(row => id(row.planPalletId) === id(duplicate.planPalletId) - && id(row.warehousePalletId) === id(duplicate.warehousePalletId)), trace(freed.response)) - proof.status = 'VERIFIED' +async function unchangedAfterRejection(io, method, path, body, code, before, dispatchNo, candidates) { + const result = await io.send(method, path, body, `拒绝${code}`) + io.check(`业务错误${code}有诊断回执`, result, [200, Number(code.slice(2))].includes(result?.http) + && String(result.body?.code) === code && Boolean(result.requestId) && Boolean(result.traceId)) + const after = await readDispatch(io, dispatchNo) + io.check('拒绝后路线、版本及实物零漂移', null, dispatchRouteMatches(after, candidates) && dispatchBusinessSnapshot(after) === dispatchBusinessSnapshot(before)) + return after } - -async function runRelabelDispatch(context, relabel) { - const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选', - query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' })) - const candidates = (Array.isArray(candidateResult.data) ? candidateResult.data : []) - .filter((value) => value?.sourceStage === 'RELABEL' - && relabel.targetPalletIds.includes(id(value?.warehousePalletId))) - const actualMarks = candidates.flatMap((value) => String(value?.relabelCartonMarks || '') - .split(',').map((mark) => mark.trim()).filter(Boolean)) - context.assert(MODULE, '智能排车只带出本轮换标后的FBA箱唛', candidateResult.response, - candidates.length === relabel.targetPalletIds.length - && relabel.relabelCartonMarks.every((mark) => actualMarks.includes(mark)) - && candidates.every((value) => !String(value?.cartonMarks || '').trim()), - `pallets=${candidates.length}, relabelMarks=${actualMarks.join(',')}, normalMarks=${candidates.map((value) => value?.cartonMarks).filter(Boolean).join(',')}; ${trace(candidateResult.response)}`, - OPTIONS) - if (candidates.length !== relabel.targetPalletIds.length) return - - const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, { - planPalletIds: candidates.map((value) => id(value.warehousePalletId)), - fbaAppointmentNo: null, - fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo, - }) - const dispatchNo = id(arranged.data?.dispatchNo) - context.assert(MODULE, '换标多托盘原子预排并冻结换标箱唛', arranged.response, - Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' - && Number(arranged.data?.palletCount) === candidates.length - && Array.isArray(arranged.data?.items) - && arranged.data.items.every((item) => item?.sourceStage === 'RELABEL'), - `dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`, - OPTIONS) - if (!dispatchNo) return - Object.assign(relabel, { - dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null, - relabelNo: relabel.relabelNo, planPalletId: null, - }) - const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) - // DispatchResponse.Item omits cartonMarks. Phase5 verifies the persisted marks - // through OutboundDetail.pallets after transferring this exact dispatch. - context.assert(MODULE, '排车详情保留本轮换标托盘与箱数快照', detail.response, - success(detail.response) && dispatchBusinessSnapshot(detail.data) === dispatchBusinessSnapshot(arranged.data), - trace(detail.response), OPTIONS) +// C52只使用当前货物命令与路线命令,绝不借旧预排对已建主单做隐式编辑。 +export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate }) { + const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = { marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [], requests: [] } + const io = dispatchIo(context, MODULE, proof, 'C52'), path = `${BASE}/${encodeURIComponent(dispatchNo)}` + const read = async (label, expected) => { + const current = await readDispatch(io, dispatchNo) + io.check(label, null, dispatchRouteMatches(current, expected)); proof.stages.push({ label, snapshot: dispatchRouteSnapshot(current) }); return current + } + const mutate = async (action, suffix, fields, expected, method = 'POST') => { + const before = await readDispatch(io, dispatchNo) + const body = { recordVersion: version(before.data.recordVersion), idempotencyKey: io.key(action), ...fields } + const result = await io.command(method, `${path}${suffix}`, body, action) + io.check(`${action}恢复同一整车命令身份`, null, result?.dispatchNo === dispatchNo && versionAfter(result.recordVersion, before.data.recordVersion)) + const current = await read(action, expected) + await replay(io, `${path}${suffix}`, body, result, `${action}原键重放`, method) + io.check(`${action}重放不漂移实际行身份`, null, dispatchBusinessSnapshot(current) === dispatchBusinessSnapshot(await readDispatch(io, dispatchNo))) + return current + } + try { + if (markerCandidates(context, [...candidates, duplicate]).length !== candidates.length + 1) throw new DispatchNotRun('C52来源不属于本轮客户仓') + const initial = await read('初始多目的地', candidates), expanded = [...candidates, duplicate] + const extra = createBody(context.marker, 'C52-extra', [duplicate]) + let current = await mutate('新增同目的地第二托', '/items', { cargo: extra.cargo, batchAllocations: extra.batchAllocations }, expanded) + io.check('同目的地第二托增加箱托数但路线稳定去重', null, initial.data.dispatchInfo.route === current.data.dispatchInfo.route && Number(current.data.counts.palletCount) === Number(initial.data.counts.palletCount) + 1) + const item = current.items.items.find(i => i.sourceKey === duplicate.sourceKey) + current = await mutate('移除同目的地第二托', `/items/${encodeURIComponent(item.dispatchItemId)}/remove`, { reason: `${context.marker}-释放额外测试实物` }, candidates) + const removed = candidates.find(c => destination(c) !== destination(duplicate)), row = current.items.items.find(i => i.sourceKey === removed.sourceKey) + const reduced = candidates.filter(c => c.sourceKey !== removed.sourceKey), removePath = `${path}/items/${encodeURIComponent(row.dispatchItemId)}/remove` + current = await mutate('移除目的地最后一托', `/items/${encodeURIComponent(row.dispatchItemId)}/remove`, { reason: `${context.marker}-路线核验` }, reduced) + io.check('最后一托移除后路线删除该目的地', null, !String(current.data.dispatchInfo.route).split(',').includes(destination(removed))) + await unchangedAfterRejection(io, 'POST', removePath, { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('missing-item'), reason: '明确缺失明细' }, 'A0404', current, dispatchNo, reduced) + const add = createBody(context.marker, 'C52-restore', [removed]) + current = await mutate('重新加入已移除目的地', '/items', { cargo: add.cargo, batchAllocations: add.batchAllocations }, candidates) + const duplicateSource = sourceSelection(candidates[0]) + await unchangedAfterRejection(io, 'POST', `${path}/items`, { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('duplicate-source'), cargo: [duplicateSource, duplicateSource], batchAllocations: [] }, 'A0400', current, dispatchNo, candidates) + const otherIo = dispatchIo(context, MODULE, proof, 'C52-FOREIGN') + let other = await newDraft(otherIo, candidates[0].warehouseId) + other = await otherIo.command('POST', `${BASE}/drafts/containers`, { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('container'), draftToken: other.draftToken, capacity: 15 }, '创建本轮独立冲突草稿容器') + await unchangedAfterRejection(io, 'POST', `${BASE}/draft-locks/lock`, { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('lock-conflict'), draftToken: other.draftToken, draftContainerId: other.containers[0].draftContainerId, source: sourceSelection(candidates[0]) }, 'A0409', current, dispatchNo, candidates) + const releasedBody = { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('release'), draftToken: other.draftToken } + const released = await otherIo.command('POST', `${BASE}/draft-locks/release`, releasedBody, '释放确认未取得来源的冲突草稿') + await replay(otherIo, `${BASE}/draft-locks/release`, releasedBody, released, '释放原键重放') + const originalStops = current.route.stops, keys = originalStops.slice(1).map(s => s.destinationKey) + current = await mutate('重排实际卸货顺序', '/route', { destinationKeys: [...keys].reverse(), reason: `${context.marker}-明确既有站序` }, candidates, 'PUT') + io.check('真实起点保留且卸货顺序反转', null, same(current.route.stops[0], originalStops[0]) && same(current.route.stops.slice(1).map(s => s.destinationKey), [...keys].reverse())) + current = await mutate('恢复实际卸货顺序', '/route', { destinationKeys: keys, reason: `${context.marker}-恢复原站序` }, candidates, 'PUT') + io.check('原实际站序恢复', null, same(current.route.stops, originalStops)) + const available = await candidatePage(io, candidates[0].warehouseId) + io.check('额外实物已释放为真实候选', null, available.some(c => c.sourceKey === duplicate.sourceKey && c.blockReasons?.length === 0)) + proof.status = 'VERIFIED'; return current + } catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; throw error } +} +async function removeControl(context, io, candidate) { + const body = createBody(context.marker, 'REMOVAL-CONTROL', [candidate]) + const command = await io.command('POST', BASE, body, '正式Create创建独立移货对照') + await replay(io, BASE, body, command, '正式创建原键恢复') + let current = await readDispatch(io, command.dispatchNo) + io.check('对照车来自本轮真实来源', null, dispatchRouteMatches(current, [candidate])) + const item = current.items.items[0] + const remove = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('control-remove'), reason: `${context.marker}-未拣实物释放` } + await io.command('POST', `${BASE}/${encodeURIComponent(command.dispatchNo)}/items/${encodeURIComponent(item.dispatchItemId)}/remove`, remove, '移出原生明细') + current = await readDispatch(io, command.dispatchNo) + io.check('最后实物移出后真实数量归零', null, dispatchRouteMatches(current, [])); return command.dispatchNo +} +async function prepareDraft(context, io, candidates) { + let draft = await newDraft(io, candidates[0].warehouseId) + const change = async (action, path, fields, method = 'POST') => { + const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(action), draftToken: draft.draftToken, ...fields } + draft = await io.command(method, path, body, action); await replay(io, path, body, draft, `${action}原键重放`, method); return draft + } + await change('临时容器', `${BASE}/drafts/containers`, { capacity: 15 }) + const temporary = draft.containers[0].draftContainerId, source = sourceSelection(candidates[0]) + await change('临时选货', `${BASE}/draft-locks/lock`, { draftContainerId: temporary, source }) + const occupied = await activeDraft(io, draft.draftToken) + const invalid = await io.send('DELETE', `${BASE}/drafts/${encodeURIComponent(draft.draftToken)}/containers/${encodeURIComponent(temporary)}`, { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('nonempty-delete'), reason: '非空容器删除负例' }, '非空容器拒绝') + io.check('非空容器不能删除', invalid, [200, 409].includes(invalid?.http) && String(invalid.body?.code) === 'A0409') + io.check('拒绝删除后草稿不变', null, same(await activeDraft(io, draft.draftToken), occupied)) + await change('解除原容器来源', `${BASE}/draft-locks/unlock`, { draftContainerId: temporary, source }) + const free = await candidatePage(io, candidates[0].warehouseId) + io.check('解锁恢复真实候选', null, free.some(c => c.sourceKey === candidates[0].sourceKey && c.blockReasons?.length === 0)) + await change('删除已空容器', `${BASE}/drafts/${encodeURIComponent(draft.draftToken)}/containers/${encodeURIComponent(temporary)}`, { reason: `${context.marker}-空容器` }, 'DELETE') + await change('正式容器', `${BASE}/drafts/containers`, { capacity: 15 }) + const container = draft.containers[0].draftContainerId + for (const [n, candidate] of candidates.entries()) await change(`正式选货-${n}`, `${BASE}/draft-locks/lock`, { draftContainerId: container, source: sourceSelection(candidate) }) + await change('完整布局', `${BASE}/drafts/snapshot`, { planIds: [], containers: draft.containers }) + const own = await candidatePage(io, candidates[0].warehouseId, { draftToken: draft.draftToken }) + io.check('原草稿仅放行自己的可选来源', null, candidates.every(c => own.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) + const hidden = await candidatePage(io, candidates[0].warehouseId) + io.check('无草稿身份不能选择活动占用', null, candidates.every(c => !hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) + return draft +} +async function createRelabel(context, io, proof) { + const fixture = context.createdResources?.phase4OverseasRelabel + if (fixture?.marker !== context.marker || fixture.status !== 'READY_FOR_OUTBOUND') return + const page = await candidatePage(io, fixture.overseasWarehouseId, { orderNo: fixture.relabelNo, sourceStage: 'RELABEL' }) + const candidates = page.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo && id(c.customerId) === id(fixture.customerId) + && id(c.warehouseId) === id(fixture.overseasWarehouseId) && fixture.targetPalletIds?.map(id).includes(id(c.selection.warehousePalletId))) + if (!candidates.length || candidates.length !== fixture.targetPalletIds.length) { proof.relabel = { status: 'NOT_RUN', reason: '没有本轮完整换标申请来源' }; return } + const body = createBody(context.marker, 'RELABEL', candidates), created = await io.command('POST', BASE, body, '真实换标整单Create') + await replay(io, BASE, body, created, '换标Create原键恢复') + const current = await readDispatch(io, created.dispatchNo) + io.check('整单换标来源、客户仓及数量回查', null, dispatchMatches(current, candidates)) + Object.assign(fixture, { dispatchNo: created.dispatchNo, finalStatus: current.data.status, stage: current.data.stage, data: current.data }) + proof.relabel = { dispatchNo: created.dispatchNo, status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey), cartonMarksVerification: 'NOT_RUN_PUBLIC_DETAIL_OMITS_MARKS' } } - export async function run(context) { - context.createdResources.phase4OverseasDispatch = { - marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null, - planPalletId: null, planPalletIds: [], destinationWarehouseCodes: [], finalStatus: null, - cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded', + context.createdResources ??= {} + if (context.createdResources.phase4OverseasDispatch?.requests?.some(r => !['GET', 'HEAD'].includes(r.method))) throw Error('本轮排车已经尝试写入,须审核原回执,禁止自动重建') + const proof = context.createdResources.phase4OverseasDispatch = { marker: context.marker, status: 'CHECKING', dispatchNo: null, finalStatus: null, stage: null, requests: [] } + try { + const fixture = context.createdResources.phase4OverseasPda + markerCandidates(context, []) + let io = dispatchIo(context, MODULE, proof, 'MAIN') + const page = await candidatePage(io, fixture.overseasWarehouseId) + const routeFixture = selectDispatchRouteFixture(markerCandidates(context, page)) + if (!routeFixture) throw new DispatchNotRun('本轮同客户仓需两个FBA目的地及同目的地双实托,不借历史样本') + const selected = routeFixture.selected + ;[...selected, routeFixture.duplicate].forEach((c, n) => createBody(context.marker, `PRECHECK-${n}`, [c])) + context = await dispatchEvidenceContext(context) + io = dispatchIo(context, MODULE, proof, 'MAIN') + proof.removalDispatchNo = await removeControl(context, io, selected[0]) + const grouped = await verifyGroupedDispatch(context, MODULE, selected) + if (grouped.status !== 'GROUPED_DISPATCH_VERIFIED') throw new DispatchNotRun(grouped.reason) + const draft = await prepareDraft(context, io, selected) + proof.draftToken = draft.draftToken + const groups = [{ draftContainerId: draft.containers[0].draftContainerId, dispatch: createBody(context.marker, 'MAIN-CHILD', draft.sources) }] + const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert'), draftToken: draft.draftToken, groups } + const converted = await io.command('POST', `${BASE}/drafts/pre-arrange`, body, '真实草稿容器转换主链整车') + io.check('转换返回原服务器草稿和唯一子命令', null, converted?.draftToken === draft.draftToken && converted.vehicles?.length === 1 && converted.vehicles[0].dispatch?.action === 'CREATE') + const dispatchNo = converted.vehicles[0].dispatch.dispatchNo + let current = await readDispatch(io, dispatchNo) + io.check('转换成功后回查真实来源', null, dispatchRouteMatches(current, selected)) + await replay(io, `${BASE}/drafts/pre-arrange`, body, converted, '主链转换原键恢复') + const active = await io.command('GET', `${BASE}/drafts`, undefined, '转换后不再出现在活动草稿') + io.check('已转换草稿退出活动列表', null, Array.isArray(active) && !active.some(d => d.draftToken === draft.draftToken)) + current = await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selected, duplicate: routeFixture.duplicate }) + Object.assign(proof, { dispatchNo, warehouseId: current.warehouseId, customerId: fixture.customerId, + customerIds: sorted(new Set(current.items.items.map(i => i.customerId))), inboundCodes: sorted(new Set(current.items.items.map(i => i.inboundCode))), + planPalletIds: selected.map(c => c.selection.planPalletId), destinationWarehouseCodes: sorted(new Set(current.data.orderDetails.map(o => o.destinationFbaWarehouse))), + sourceKeys: sorted(new Set(current.data.orderDetails.map(o => o.sourceKey))), recordVersion: current.data.recordVersion, + finalStatus: current.data.status, stage: current.data.stage, data: current.data, items: current.items, route: current.route, + sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' }) + io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING') + await createRelabel(context, io, proof) + await context.checkpoint() + } catch (error) { + if (error.notRun && !proof.requests.some(r => !['GET', 'HEAD'].includes(r.method))) return notRun(context, proof, error.message) + proof.outcomeUnknown ||= context.createdResources.dispatchWriteBoundary?.outcomeUnknown === true + proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message + try { await context.checkpoint?.() } catch (persistError) { proof.persistenceError = persistError.message } + throw error } - - const initialPage = await api(context, 'GET', '查询海外排车分页', query(BASE, { page: 1, size: 20 })) - context.assert(MODULE, '排车分页包络稳定', initialPage.response, - Number.isFinite(Number(initialPage.data?.page)) && Number.isFinite(Number(initialPage.data?.size)) - && Number.isFinite(Number(initialPage.data?.total)) && Array.isArray(initialPage.data?.records), - `records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS) - if (!success(initialPage.response)) return - - const relabel = context.createdResources?.phase4OverseasRelabel - if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') { - await runRelabelDispatch(context, relabel) - if (relabel.dispatchNo) await settleRelabelFulfillment(context, relabel) - } - - const fixture = markerFixture(context) - if (!fixture) { - blocker(context, '取得本轮海外PDA已收货托盘前置', - `marker=${context.marker}, pdaMarker=${context.createdResources?.phase4OverseasPda?.marker || 'missing'}, status=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`) - return - } - - const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选', - query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : [] - const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId)) - && id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId) - && id(value?.customerId) === id(fixture.customerId) && value?.destinationType === 'FBA' - && id(value?.planId) && id(value?.planPalletId) - && value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0) - const routeFixture = selectDispatchRouteFixture(markerCandidates) - if (!routeFixture) { - blocker(context, 'C52 多目的地及同目的地双托前置', '本轮同客户同仓实际收货候选须至少两个FBA目的地,且至少一个目的地有两个不同实物托盘;不借历史数据') - return - } - const selectedCandidates = routeFixture.selected - const candidatesByDestination = new Map(selectedCandidates.map(row => [destination(row), row])) - const candidate = selectedCandidates[0] - context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response, - selectedCandidates.length >= 2, - `candidates=${selectedCandidates.length}, destinations=${[...candidatesByDestination.keys()].join(',')}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS) - if (selectedCandidates.length < 2) return - - const planId = id(candidate.planId) - const planPalletId = id(candidate.planPalletId) - const planIds = [...new Set(selectedCandidates.map(value => id(value.planId)))] - const planPalletIds = selectedCandidates.map(value => id(value.planPalletId)) - // Exercise removal on a disposable dispatch, then reuse the released current-run pallet. - const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, { - planPalletIds: [planPalletId], - }) - const removableNo = id(removable.data?.dispatchNo) - const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId) - if (!success(removable.response) || !removableNo || !removableItem?.id) { - blocker(context, '移除对照排车前置', '本轮托盘未生成排车明细', removable.response) - return - } - const removePath = `${BASE}/${encodeURIComponent(removableNo)}/items/${encodeURIComponent(id(removableItem.id))}` - const removed = await api(context, 'DELETE', '排车移除未拣托盘并释放正式锁', removePath) - const removedDetail = await api(context, 'GET', '回查移除后排车数量', `${BASE}/${encodeURIComponent(removableNo)}`) - context.assert(MODULE, '移除最后托盘后箱数托数明细归零', removedDetail.response, - success(removed.response) && success(removedDetail.response) - && Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0 - && removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS) - const removedAgain = await context.request('DELETE', removePath) - context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain, - removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS) - const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - context.assert(MODULE, '正式锁释放后候选恢复', freed.response, - Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS) - context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo - await verifyGroupedDispatch(context, MODULE, selectedCandidates) - const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64) - const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }]) - const snapshotBody = { draftToken, planIds, containersJson } - context.createdResources.phase4OverseasDispatch.draftToken = draftToken - context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId - context.createdResources.phase4OverseasDispatch.planPalletIds = planPalletIds - context.createdResources.phase4OverseasDispatch.destinationWarehouseCodes = [...candidatesByDestination.keys()] - - const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody) - context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response, - success(snapshot.response) && (snapshot.response.body?.data == null), trace(snapshot.response), OPTIONS) - if (!success(snapshot.response)) return - const snapshotReplay = await api(context, 'POST', '相同草稿快照重复保存', `${BASE}/drafts/snapshot`, snapshotBody) - if (!success(snapshotReplay.response)) return - - const draftsResult = await api(context, 'GET', '回查当前操作员智能预排草稿', `${BASE}/drafts`) - const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : [] - const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken) - context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response, - matchingDrafts.length === 1 && planIds.every(value => String(matchingDrafts[0].planIdsJson || '').split(',').includes(value)) - && matchingDrafts[0].containersJson === containersJson, - `matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS) - - const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }) - const containerId = id(disposableContainer.data?.id) - if (!success(disposableContainer.response) || !containerId) return - const boundBody = { planPalletId, draftToken, draftContainerId: containerId } - const bound = await api(context, 'POST', '托盘绑定真实草稿柜', `${BASE}/draft-locks/lock`, boundBody) - if (!success(bound.response)) return - const boundDrafts = await api(context, 'GET', '回查真实草稿柜及托盘绑定', `${BASE}/drafts`) - context.assert(MODULE, '草稿结构回显服务端柜ID和托盘', boundDrafts.response, - boundDrafts.data?.find(draft => draft.draftToken === draftToken)?.containers?.some(container => - id(container.id) === containerId && container.palletIds?.map(id).includes(planPalletId)), trace(boundDrafts.response), OPTIONS) - await api(context, 'POST', '移出草稿托盘解锁', `${BASE}/draft-locks/unlock`, boundBody) - const unlocked = await api(context, 'GET', '解锁后回查候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - context.assert(MODULE, '草稿解锁恢复候选', unlocked.response, - unlocked.data?.some(item => id(item.planPalletId) === planPalletId), trace(unlocked.response), OPTIONS) - await api(context, 'POST', '重新锁定以验证删除柜释放锁', `${BASE}/draft-locks/lock`, boundBody) - await api(context, 'DELETE', '删除本轮草稿柜并释放绑定托盘', `${BASE}/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`) - const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`) - context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response, - Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS) - const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }) - if (!success(finalContainer.response) || !finalContainer.data?.id) return - const lockBodies = planPalletIds.map(value => ({ planPalletId: value, draftToken, draftContainerId: id(finalContainer.data.id) })) - for (const [index, body] of lockBodies.entries()) { - const locked = await api(context, 'POST', `锁定本轮预排托盘${index + 1}`, `${BASE}/draft-locks/lock`, body) - if (!success(locked.response)) return - } - const lockBody = lockBodies[0] - const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody) - context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response, - success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS) - - const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选', - query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken })) - const visibleIds = new Set((Array.isArray(visibleInDraft.data) ? visibleInDraft.data : []).map(value => id(value?.planPalletId))) - const visible = planPalletIds.every(value => visibleIds.has(value)) - context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible, - `planPalletIds=${planPalletIds.join(',')}; ${trace(visibleInDraft.response)}`, OPTIONS) - - const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘', - query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - const hiddenIds = new Set((Array.isArray(hiddenResult.data) ? hiddenResult.data : []).map(value => id(value?.planPalletId))) - const hidden = planPalletIds.every(value => !hiddenIds.has(value)) - context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden, - `planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS) - - const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64) - const foreignLock = await context.request('POST', `${BASE}/draft-locks/lock`, { - planPalletId, draftToken: foreignToken, - }) - context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) - - const preArrangeBody = { - planPalletIds, fbaAppointmentNo: null, - fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken, - } - const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody) - const dispatchNo = id(arranged.data?.dispatchNo) - context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response, - Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' - && Array.isArray(arranged.data?.items) - && planPalletIds.every(value => arranged.data.items.some(item => id(item?.planPalletId) === value)) - && Number(arranged.data?.palletCount) === planPalletIds.length, - `dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}, destinations=${[...candidatesByDestination.keys()].join(',')}; ${trace(arranged.response)}`, OPTIONS) - if (!dispatchNo) return - context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo - context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id) - context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null - - const duplicateArrange = await context.request('POST', `${BASE}/pre-arrange`, preArrangeBody) - context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange, - success(duplicateArrange) - && dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data), - trace(duplicateArrange), OPTIONS) - - const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, { - page: 1, size: 20, dispatchNo, - })) - const row = records(pageResult.data).find((value) => id(value?.dispatchNo) === dispatchNo) - context.assert(MODULE, '排车分页包含本轮marker预排且状态未推进', pageResult.response, - row?.status === 'PENDING_DEPARTURE' && id(row?.id) === id(arranged.data?.id), - `dispatchNo=${dispatchNo}, status=${row?.status || 'missing'}; ${trace(pageResult.response)}`, OPTIONS) - - const detailResult = await api(context, 'GET', '查询本轮排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) - context.assert(MODULE, '排车详情按记录自身客户范围加载托盘明细', detailResult.response, - id(detailResult.data?.dispatchNo) === dispatchNo && detailResult.data?.status === 'PENDING_DEPARTURE' - && Array.isArray(detailResult.data?.items) - && detailResult.data.items.some((item) => id(item?.planPalletId) === planPalletId), - `dispatchNo=${id(detailResult.data?.dispatchNo) || 'missing'}, items=${detailResult.data?.items?.length ?? 'missing'}; ${trace(detailResult.response)}`, OPTIONS) - - await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selectedCandidates, - duplicate: routeFixture.duplicate, draftToken, draftContainerId: id(finalContainer.data.id) }) - - const released = await api(context, 'POST', '释放已转换后的草稿资源', `${BASE}/draft-locks/release`, { draftToken }) - if (success(released.response)) { - const releaseReplay = await api(context, 'POST', '重复释放草稿幂等成功', `${BASE}/draft-locks/release`, { draftToken }) - context.assert(MODULE, '重复释放不影响正式排车锁且Void响应可为空', releaseReplay.response, - success(releaseReplay.response) && releaseReplay.response.body?.data == null, trace(releaseReplay.response), OPTIONS) - } - - const afterRelease = await api(context, 'GET', '释放后回查活动草稿', `${BASE}/drafts`) - context.assert(MODULE, '释放后当前token不再出现在活动草稿', afterRelease.response, - Array.isArray(afterRelease.data) && !afterRelease.data.some((value) => value?.draftToken === draftToken), - `draftToken=${draftToken}; ${trace(afterRelease.response)}`, OPTIONS) + return proof } diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 4077802ab..bf99f7a13 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,9 +1,11 @@ import test from 'node:test' import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, - dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs' + dispatchDestinationConservation, pickAll, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArguments } from '../admin-test-env-acceptance.mjs' @@ -27,27 +29,26 @@ test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才 } }) -test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{ - const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']} - const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'}, - {planPalletId:'11',destinationWarehouseCode:'LAX9'},{planPalletId:'12',fbaWarehouseCode:'ONT8'}]} - const deliveries=[{destinationWarehouseCode:'LAX9'},{destinationWarehouseCode:'ONT8'}] - assert.equal(dispatchDestinationConservation(detail,fixture),true) - assert.equal(dispatchDestinationConservation(detail,fixture,deliveries),true) - for(const mutate of [ - value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER', - value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER', - value=>value.detail.items[0].destinationWarehouseCode='REASSIGNED', - value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10', - value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(), - value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}), - value=>value.deliveries.push({destinationWarehouseCode:'ONT8'}), - ]) { - const value=structuredClone({detail,fixture,deliveries});mutate(value) - assert.equal(dispatchDestinationConservation(value.detail,value.fixture,value.deliveries),false) +test('native route conserves source keys and destinations while same destination may contain multiple customers', () => { + const fixture = { sourceKeys: ['PLAN:10', 'PLAN:11', 'PLAN:12'], destinationWarehouseCodes: ['ONT8', 'LAX9'] } + const detail = { dispatchInfo: { route: 'ONT8,LAX9' }, orderDetails: [ + { sourceKey: 'PLAN:10', customerId: '100', destinationFbaWarehouse: 'ONT8' }, + { sourceKey: 'PLAN:11', customerId: '200', destinationFbaWarehouse: 'LAX9' }, + { sourceKey: 'PLAN:12', customerId: '200', destinationFbaWarehouse: 'ONT8' }] } + const deliveries = [{ customerId: '100', destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } }, + { customerId: '200', destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } }, + { customerId: '200', destination: { key: 'FBA:LAX9', warehouseCode: 'LAX9' } }] + assert.equal(dispatchDestinationConservation(detail, fixture), true) + assert.equal(dispatchDestinationConservation(detail, fixture, deliveries), true) + for (const mutate of [value => { value.detail.dispatchInfo.route = 'ONT8' }, + value => { value.detail.dispatchInfo.route = 'ONT8,ONT8,LAX9' }, + value => { value.detail.orderDetails[1].destinationFbaWarehouse = 'OTHER' }, + value => { value.detail.orderDetails[1].sourceKey = 'PLAN:99' }, + value => { value.fixture.destinationWarehouseCodes.push('UNSELECTED') }, + value => { value.deliveries.pop() }, value => { value.deliveries.push(value.deliveries[0]) }]) { + const value = structuredClone({ detail, fixture, deliveries }); mutate(value) + assert.equal(dispatchDestinationConservation(value.detail, value.fixture, value.deliveries), false) } - assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch\)/) - assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch,deliveries\)/) }) test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => { @@ -61,40 +62,38 @@ test('phase-five fulfillment is a registered default HTTP module after the real } }) -test('phase-five module covers dispatch edit, transfer, PDA pick/depart and delivery state', () => { - for (const token of [ - "const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'", - "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", - 'query(MERCHANT_DELIVERY_BASE, { dispatchNo })', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick', - '/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}', - '/delivery-orders/${encodeURIComponent(currentNo)}/status', - ]) assert.ok(source.includes(token), token) - assert.match(source, /WAIT_OUTBOUND/) - assert.match(source, /WAIT_DELIVERY/) - assert.match(source, /READY_TO_DEPART/) - assert.match(source, /OUTBOUNDED/) - assert.match(source, /DELIVERING/) - assert.match(source, /expectedVersion: deliveryVersion/) - assert.match(source, /transferDeliveries\(transferred\.data\)/) - assert.match(source, /new Set\(destinationKeys\)\.size === deliveries\.length/) - assert.match(source, /deliveries\.reduce\(\(sum, value\) => sum \+ Number\(value\?\.packageCount \|\| 0\), 0\)/) +test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => { + for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'", + "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", '/transfer-delivery', '/delivery-arrangement', + 'cargoHash: value.cargoHash', 'loadingCompletionHash: value.loadingCompletionHash', + 'recordVersion: version(detail.data.recordVersion)', 'positionId: id', + 'departed.data.result?.shippedPackages', 'outboundRef.outboundNo']) { + if (token === 'positionId: id') assert.match(source, /positionId: source.position \? id\(source.position.positionId\)/) + else assert.ok(source.includes(token), token) + } + assert.doesNotMatch(source, /READY_TO_DEPART|PENDING_DEPARTURE|DISPATCH-COMPLETE|\/items\/.*['"]DELETE['"]/) + assert.match(source, /data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'/) + assert.match(source, /departed.data.stage === 'DELIVERING'/) + const run = source.slice(source.indexOf('export async function run')) + assert.ok(run.indexOf('prepareFleetTrip(') < run.indexOf('await pickAll(')) + assert.ok(run.indexOf('await pickAll(') < run.indexOf('await confirmFleetLoading(')) + assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await arrangeDispatchDelivery(')) + assert.ok(run.indexOf('await arrangeDispatchDelivery(') < run.indexOf('await verifyDepartureReadiness(')) + assert.ok(run.indexOf('全部客户正常签收汇总整车DELIVERED') < run.indexOf('await finishFleetTrip(')) + assert.ok(run.indexOf('await finishFleetTrip(') < run.indexOf('const returnRequest')) }) -test('phase-five module proves the client delivery create, list and nested detail contract', () => { - assert.match(source, /const MERCHANT_DELIVERY_BASE = '\/api\/merchant\/v1\/last-leg\/delivery-orders'/) +test('client reads its own native-arranged deliveries and full nested document contract', () => { assert.match(source, /loginMerchant\(context, customerId\)/) - assert.match(source, /context\.options\?\.merchantUsername/) - assert.match(source, /\/api\/merchant\/v1\/iam\/tenant\/profile/) - assert.match(source, /id\(profile\?\.id\) === customerId/) - assert.match(source, /客户创建派送并原子转出库单/) - assert.match(source, /deliveryMethod: 'TRUCK_PALLET'/) - assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',\s*currency: 'USD', amount: 10/) + assert.match(source, /context.options\?.merchantUsername/) + assert.match(source, /id\(profile\?.id\) === customerId/) assert.match(source, /客户分页回查自身派送单/) assert.match(source, /客户读取自身派送详情/) - assert.match(source, /merchantDetail\.data\?\.order\?\.deliveryNo/) - assert.match(source, /merchantDetail\.data\?\.skus/) - assert.match(source, /merchantDetail\.data\?\.fees/) - assert.match(source, /merchantDetail\.data\?\.operations/) + assert.match(source, /merchantDetail.data\?.order\?.deliveryNo/) + assert.match(source, /merchantDetail.data\?.skus/) + assert.match(source, /merchantDetail.data\?.fees/) + assert.match(source, /merchantDetail.data\?.operations/) + assert.doesNotMatch(source, /merchant\(context, merchantToken, 'POST'.*创建派送/) }) test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => { @@ -134,10 +133,9 @@ test('phase-five module uses marker-owned fixtures and always disables its tempo }) test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => { - assert.match(source, /idempotencyKey: key\(context, 'TRANSFER'\)/) + assert.match(source, /idempotencyKey: key\(context, 'TRANSFER', dispatchNo\)/) assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/) - assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-COMPLETE'\)/) - assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART'\)/) + assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART', dispatchNo\)/) assert.match(source, /addCoverageGap\(MODULE, SCENARIO/) assert.match(source, /deploymentBlocker: true/) }) @@ -153,9 +151,10 @@ test('relabel marks are checked on the authoritative outbound DTO without weaken assert.match(source, /relabel\?\.marker !== context\.marker/) }) -test('manual exception retries only a transport failure with the identical business body', () => { - assert.match(source, /manualException\.response\.http === 0/) - assert.match(source, /人工异常连接失败后使用同载荷重试/) +test('unknown writes preserve the original command and stop instead of transport retry', () => { + assert.match(source, /entry.status = 'UNKNOWN'/) + assert.match(source, /存在未知写结果/) + assert.doesNotMatch(source, /人工异常连接失败后使用同载荷重试/) assert.match(source, /manualExceptionBody/) }) @@ -497,34 +496,28 @@ test('inventory evidence captures zero drift independently for every balance buc } }) -test('departure gate requires the PDA task COMPLETED, server canDepart, and independent matching dispatch READY_TO_DEPART', async () => { - for (const [taskStatus, canDepart, dispatchStatus, dispatchNo, expected] of [ - ['COMPLETED', true, 'READY_TO_DEPART', 'DS-1', true], - ['READY_TO_DEPART', true, 'READY_TO_DEPART', 'DS-1', false], - ['PICKING', true, 'READY_TO_DEPART', 'DS-1', false], - ['COMPLETED', false, 'READY_TO_DEPART', 'DS-1', false], - ['COMPLETED', true, 'PENDING_DEPARTURE', 'DS-1', false], - ['COMPLETED', true, 'DEPARTED', 'DS-1', false], - ['COMPLETED', true, 'READY_TO_DEPART', 'DS-OTHER', false], - ]) { - const context = contextFor((method, path) => path.startsWith('/api/pda/') - ? ok({ dispatchNo: 'DS-1', status: taskStatus, canDepart }) : ok({ dispatchNo, status: dispatchStatus })) - assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), expected) +test('departure requires independent matching native stage, shared version, reconciled picks and both action gates', async () => { + const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993', + pickingReconciled: true, actions: { DEPART: { allowed: true } } } + for (const change of [null, { stage: 'LOADING' }, { stage: 'DELIVERING' }, { status: 'CANCELLED' }, + { dispatchNo: 'OTHER' }, { recordVersion: '9007199254740994' }, { pickingReconciled: false }, + { actions: { DEPART: { allowed: false } } }]) { + const context = contextFor((method, path) => ok(path.startsWith('/api/pda/') ? valid : { ...valid, ...change })) + assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), change == null) assert.equal(context.calls.length, 2) assert.equal(context.calls.some(call => call.method !== 'GET'), false) - assert.equal(context.assertions.some(row => !row.passed), !expected) } }) test('G05 freezes actual PDA balance IDs and measures raw sources independently from grouped representatives without inbound filters', async () => { const task = { dispatchNo: 'DS-1', warehouseId: 'WH-1', items: [ - { positions: [{ id: '11', balanceId: '1' }, { id: '12', balanceId: '2' }, { id: '11', balanceId: '1' }] }] } + { positions: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }, { positionId: '11', balanceId: '1' }] }] } assert.deepEqual(dispatchInventorySources(task, 'DS-1', 'WH-1'), { sourceBalanceIds: ['1', '2'], sourcePositionIds: ['11', '12'], sourcePositionBalances: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }] }) assert.equal(dispatchInventorySources(task, 'OTHER', 'WH-1'), null) assert.equal(dispatchInventorySources(task, 'DS-1', 'OTHER'), null) - assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ id: '11' }] }] }, 'DS-1', 'WH-1'), null) + assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ positionId: '11' }] }] }, 'DS-1', 'WH-1'), null) let shipped = false const source = balanceId => ({ id: balanceId, customerId: '100', overseasWarehouseId: 'WH-1', destinationWarehouseCode: 'FBA', skuCode: 'MIXED', totalPackages: balanceId === '1' ? 3 : 6, @@ -761,3 +754,129 @@ test('all fragments completed still requires raw aggregate history and matching ? ok({ summary: { orderId: '1002', mainStatus: 'COMPLETED' } }) : originalRequest(...args) assert.equal((await verifyFulfillmentRootStatus(foreignProjection.context, foreignProjection.args)).passed, false) }) + + +test('native PDA scans use exact current versions including zero, immutable replay and stale-version rejection without implicit loading', async () => { + const values = ['0', '9007199254740993', '9007199254740994'] + let currentVersion = values[0], picked = 0, firstBody, firstResult + const context = contextFor((method, path, body) => { + if (method === 'GET') return ok({ dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion, + pickingReconciled: picked === 2, counts: { packageCount: 2, pickedPackageCount: picked }, + items: [{ dispatchItemId: '9007199254740995', customerId: '100', scheduledPackages: 2, pickedPackages: picked, + positions: [{ positionId: '9007199254740996', balanceId: '9007199254740997', availablePackages: 1 }, + { positionId: '9007199254740998', balanceId: '9007199254740999', availablePackages: 1 }] }] }) + assert.match(path, /items\/9007199254740995\/pick$/) + assert.equal(Object.hasOwn(body, 'expectedVersion'), false) + assert.equal(typeof body.positionId, 'string') + if (body.idempotencyKey.endsWith('-STALE')) { + assert.equal(body.recordVersion, '0'); return { http: 200, body: { code: 'A0409' } } + } + if (firstBody?.idempotencyKey === body.idempotencyKey) { + assert.deepEqual(body, firstBody); return ok({ ...firstResult, replayed: true }) + } + assert.equal(body.recordVersion, currentVersion) + picked += 1; currentVersion = values[picked] + firstBody = structuredClone(body) + firstResult = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion, + result: { dispatchItemId: '9007199254740995', pickedPackages: picked }, counts: { pickedPackageCount: picked } } + return ok(firstResult) + }) + assert.equal(await pickAll(context, 'PDA-IN-MEMORY', 'DS-1'), true) + assert.equal(picked, 2) + assert.equal(context.calls.some(call => /loading-complete|\/complete|\/depart/.test(call.path)), false) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + +function nativeArrangementPreview(recordVersion = '0') { + return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'CARGO', + loadingCompletionHash: 'LOAD', arrange: { allowed: true }, groups: ['100', '200'].map(customerId => ({ + groupKey: `GROUP-${customerId}`, customerId, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, + groupCargoHash: `GROUP-HASH-${customerId}`, externalFreightHash: 'FREIGHT', feeCoverageCargoHash: 'COVERAGE', + requiredServices: ['DELIVERY_FEE'], feeSources: [], cargo: [{ customerId, packages: 1 }], blockReasons: [] })) } +} + +test('native arrangement carries frozen group and cost hashes, preserves all customer outbounds, and replays the exact original command', async () => { + for (const recordVersion of ['0', '9007199254740993']) { + const preview = nativeArrangementPreview(recordVersion) + let original, result + const context = contextFor((method, path, body) => { + if (method === 'GET') { assert.match(path, /delivery-arrangement$/); return ok(preview) } + assert.match(path, /transfer-delivery$/) + if (original) { assert.deepEqual(body, original); return ok({ ...result, replayed: true }) } + original = structuredClone(body) + assert.equal(body.recordVersion, recordVersion) + assert.equal(body.cargoHash, 'CARGO'); assert.equal(body.loadingCompletionHash, 'LOAD') + assert.deepEqual(body.groups.map(row => row.groupKey), ['GROUP-100', 'GROUP-200']) + assert.ok(body.groups.every(row => row.externalFreightHash === 'FREIGHT' && row.feeCoverageCargoHash === 'COVERAGE')) + result = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740994', + result: { dispatchNo: 'DS-1', cargoHash: 'CARGO', outbounds: ['100', '200'].map(customerId => ({ + customerId, outboundNo: `OB-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 1 })), + deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, + deliveryNo: `DL-${customerId}`, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, status: 'WAIT_DELIVERY', packageCount: 1 })) } } + return ok(result) + }) + const arranged = await arrangeDispatchDelivery(context, 'DS-1') + assert.deepEqual(arranged.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200']) + assert.equal(context.calls.filter(call => call.method === 'POST').length, 2) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + } +}) + +test('arrangement cannot run before loading or with missing hashes, groups or server gate', async () => { + for (const mutate of [value => { value.stage = 'LOADING' }, value => { value.status = 'CANCELLED' }, + value => { value.cargoHash = '' }, value => { value.loadingCompletionHash = '' }, + value => { value.groups = [] }, value => { value.arrange.allowed = false }, + value => { value.groups[0].blockReasons = ['MISSING_FEE'] }]) { + const preview = nativeArrangementPreview(); mutate(preview) + const context = contextFor((method) => { assert.equal(method, 'GET'); return ok(preview) }) + assert.equal(await arrangeDispatchDelivery(context, 'DS-1'), null) + assert.ok(context.assertions.some(row => !row.passed)) + } +}) + +for (const [name, outcome] of [ + ['transport', { http: 0, body: { code: '' } }], + ['infrastructure', { http: 503, body: { code: 'S0503' } }], + ['malformed success', { http: 200, body: { code: '200', data: null } }], + ['missing business response', { http: 200, body: {} }], +]) test(`write intent persists and ${name} stops commands and checkpoint restarts`, async () => { + const dir = await mkdtemp(join(tmpdir(), 'phase5-contract-')) + try { + const context = contextFor(async (method, path, body) => { + const saved = JSON.parse(await readFile(context.createdResources.phase5OverseasFulfillment.checkpointPath, 'utf8')) + assert.equal(saved.requests.at(-1).status, 'SENDING') + assert.equal(saved.requests.at(-1).body.recordVersion, '0') + assert.equal(saved.requests.at(-1).body.idempotencyKey, body.idempotencyKey) + assert.equal(JSON.stringify(saved).includes('SECRET-CREDENTIAL'), false) + return { ...outcome, requestId: 'REQ-UNKNOWN', traceId: 'TRACE-UNKNOWN' } + }) + context.options.output = join(dir, 'report.json') + context.createdResources.phase5OverseasFulfillment = {} + const scoped = await fulfillmentEvidenceContext(context) + const body = { recordVersion: '0', idempotencyKey: 'ORIGINAL-COMMAND', password: 'SECRET-CREDENTIAL' } + await assert.rejects(scoped.request('POST', '/api/platform/v1/last-leg/dispatches/DS-1/depart', body), /结果未知/) + await assert.rejects(scoped.request('POST', '/api/platform/v1/last-leg/dispatches/DS-1/depart', { ...body, idempotencyKey: 'NEW-KEY' }), /未知写结果/) + assert.equal(context.calls.length, 1) + const saved = JSON.parse(await readFile(context.createdResources.phase5OverseasFulfillment.checkpointPath, 'utf8')) + assert.equal(saved.status, 'UNKNOWN'); assert.equal(saved.requests[0].status, 'UNKNOWN') + assert.equal(saved.requests[0].requestId, 'REQ-UNKNOWN') + await assert.rejects(fulfillmentEvidenceContext(context), /已存在本轮履约检查点/) + } finally { await rm(dir, { recursive: true, force: true }) } +}) + + +test('default normal completion returns before exception writes; explicit legacy return stays after its original receipt guards and before closure', () => { + const run = source.slice(source.indexOf('export async function run')) + const start = run.indexOf('if (context.context?.phase5TailVerification?.runReturnScenario !== true)') + const exception = run.indexOf('const manualExceptionBody') + assert.ok(start > run.indexOf("savedVisit.data?.visit?.status !== 'COMPLETED'")) + const normalBranch = run.slice(start, exception) + assert.ok(normalBranch.includes("finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT'")) + assert.match(normalBranch, /await finishFleetTrip/) + assert.match(normalBranch, /return\s*}/) + assert.doesNotMatch(normalBranch, /createDeliveryReturn|completeReturnedException|exception-orders|mark-return/) + const explicitBranch = run.slice(exception) + assert.ok(explicitBranch.indexOf('const returnReceipt = await completeReturnedException') < explicitBranch.indexOf('await finishFleetTrip')) + assert.match(source, /const sourceBalanceIds = owned.inventoryProof\?.sourceBalanceIds/) + assert.match(source, /原returnSource位置必须存在于正式排车冻结真实余额中/) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 03c355e1b..57a746812 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -2,6 +2,9 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overs import { run as createMerchant } from './merchant-qualification-fixture.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs' +import { mkdir, writeFile, rename, readFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { createHash } from 'node:crypto' const MODULE = 'phase5-overseas-fulfillment' const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' @@ -11,7 +14,86 @@ const LASTLEG_BASE = '/api/platform/v1/last-leg' const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches' const MERCHANT_DELIVERY_BASE = '/api/merchant/v1/last-leg/delivery-orders' -function id(value) { return value == null ? '' : String(value) } +function id(value) { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('业务ID精度无效') + return value == null ? '' : String(value) +} +function version(value) { + if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) + throw new Error('服务端recordVersion缺失或精度无效') + return value +} + +// 发送前保存原命令;未知写结果只保留证据,禁止本轮继续推进。 +export async function fulfillmentEvidenceContext(context) { + const marker = String(context.marker) + if (!/^[A-Za-z0-9_-]+$/.test(marker)) throw new Error('验收marker不能用于独占检查点') + const path = resolve(dirname(context.options?.output || 'reports/admin-test-env.json'), 'phase5-checkpoints', `${marker}.json`) + try { await readFile(path); throw new Error(`已存在本轮履约检查点,必须核对原命令结果后续验:${path}`) } + catch (error) { if (error.code !== 'ENOENT') throw error } + const redact = value => { + if (Array.isArray(value)) return value.map(redact) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value) + .filter(([name]) => !/password|token|authorization|base64|^bytes$|^content$/i.test(name)) + .map(([name, nested]) => [name, redact(nested)])) + return value + } + const evidence = { marker, status: 'PREPARED', requests: [] } + const scoped = Object.create(context) + let stopped = false + scoped.checkpoint = async () => { + await mkdir(dirname(path), { recursive: true }) + await writeFile(`${path}.tmp`, `${JSON.stringify(redact({ ...evidence, + fulfillment: context.createdResources.phase5OverseasFulfillment, + fleetTrips: context.createdResources.fleetTrips }), null, 2)}\n`, 'utf8') + await rename(`${path}.tmp`, path) + } + scoped.request = async (method, requestPath, body, ...rest) => { + if (method === 'GET' || /\/auth\//.test(requestPath)) return context.request(method, requestPath, body, ...rest) + const identityCleanup = method === 'PUT' && /^\/api\/platform\/v1\/iam\/users\/[0-9]+\/status$/.test(requestPath) && body?.status === 'DISABLED' + if (stopped && !identityCleanup) throw new Error('存在未知写结果,保留原命令并停止后续写入') + const entry = { method, path: requestPath, body: redact(body), status: 'SENDING' } + evidence.requests.push(entry) + await scoped.checkpoint() + let response + try { response = await context.request(method, requestPath, body, ...rest) } + catch (error) { + stopped = true; entry.status = 'UNKNOWN'; evidence.status = 'UNKNOWN'; await scoped.checkpoint(); throw error + } + const nativeCommand = /^\/api\/(?:platform|pda)\/v1\/last-leg\/dispatches(?:\/|$)/.test(requestPath) + const knownResponse = response?.http > 0 && response.http < 500 && response.body?.code != null + && !(nativeCommand && code(response) === '200' && response.body.data == null) + Object.assign(entry, { status: knownResponse ? 'RESPONDED' : 'UNKNOWN', + http: response?.http, code: code(response), requestId: response?.requestId, traceId: response?.traceId, + result: redact(response?.body?.data) }) + if (entry.status === 'UNKNOWN') { stopped = true; evidence.status = 'UNKNOWN' } + await scoped.checkpoint() + if (stopped && !identityCleanup) throw new Error(`履约写入结果未知,停止并保留原命令;${trace(response)}`) + return response + } + scoped.requestMultipart = async (method, requestPath, file, ...rest) => { + const bytes = file?.content == null ? null : Buffer.from(file.content) + const metadata = { name: file?.name, type: file?.type, size: bytes?.length, + sha256: bytes ? createHash('sha256').update(bytes).digest('hex') : null } + // multipart也必须在发送前落盘;证据只含文件摘要而不含文件字节。 + const entry = { method, path: requestPath, file: metadata, status: 'SENDING' } + if (stopped) throw new Error('未知写结果后禁止继续上传') + evidence.requests.push(entry); await scoped.checkpoint() + let response + try { response = await context.requestMultipart(method, requestPath, file, ...rest) } + catch (error) { stopped = true; entry.status = 'UNKNOWN'; evidence.status = 'UNKNOWN'; await scoped.checkpoint(); throw error } + Object.assign(entry, { status: response?.http > 0 && response.http < 500 ? 'RESPONDED' : 'UNKNOWN', + http: response?.http, code: code(response), requestId: response?.requestId, traceId: response?.traceId, + result: redact(response?.body?.data) }) + if (entry.status === 'UNKNOWN') { stopped = true; evidence.status = 'UNKNOWN' } + await scoped.checkpoint() + if (stopped) throw new Error(`上传结果未知,保留原文件摘要并停写;${trace(response)}`) + return response + } + context.createdResources.phase5OverseasFulfillment.checkpointPath = path + await scoped.checkpoint() + return scoped +} function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response) { return String(response?.body?.code ?? '') } @@ -52,7 +134,7 @@ async function merchant(context, token, method, name, path, body = undefined) { } function transferDeliveries(data) { - return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : [] + return Array.isArray(data?.result?.deliveries) ? data.result.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : [] } function deliveryNumbers(data) { @@ -63,21 +145,19 @@ export function dispatchDestinationConservation(detail, fixture, deliveries = nu const codes = values => values.map(value => id(value).trim().toUpperCase()) const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank) && right.every(nonBlank) && JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort()) - const items = detail?.items - if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.planPalletIds) - || !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.route)) return false - const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id) - if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length - || new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false - if (items.some(row => nonBlank(row.fbaWarehouseCode) && nonBlank(row.destinationWarehouseCode) - && id(row.fbaWarehouseCode).trim().toUpperCase() !== id(row.destinationWarehouseCode).trim().toUpperCase())) return false - const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode)) - const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(',')) + const items = detail?.orderDetails + if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.sourceKeys) + || !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.dispatchInfo?.route)) return false + const actualIds = items.map(row => id(row.sourceKey)), expectedIds=fixture.sourceKeys.map(id) + if (!actualIds.every(nonBlank) || !sameSet(actualIds,expectedIds)) return false + const actual = codes(items.map(row => row.destinationFbaWarehouse)) + const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.dispatchInfo.route.split(',')) if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false if (deliveries == null) return true if (!Array.isArray(deliveries) || !deliveries.length) return false - const delivered=codes(deliveries.map(row => row.destinationWarehouseCode)) - return delivered.length === new Set(delivered).size && sameSet(delivered,actual) + const delivered=codes(deliveries.map(row => row.destination?.warehouseCode)) + const groups = deliveries.map(row => `${id(row.customerId)}:${id(row.destination?.key)}`) + return groups.every(nonBlank) && new Set(groups).size === groups.length && sameSet(delivered,actual) } async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { @@ -223,12 +303,12 @@ export function dispatchInventorySources(task, dispatchNo, warehouseId) { if (task?.dispatchNo !== dispatchNo || id(task.warehouseId) !== warehouseId || !Array.isArray(task.items) || !task.items.length) return null const positions = task.items.flatMap(item => Array.isArray(item.positions) ? item.positions : []) - if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.id)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null + if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.positionId)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null const references = new Map() for (const row of positions) { - const previous = references.get(id(row.id)) + const previous = references.get(id(row.positionId)) if (previous && previous !== id(row.balanceId)) return null - references.set(id(row.id), id(row.balanceId)) + references.set(id(row.positionId), id(row.balanceId)) } return { sourceBalanceIds: [...new Set(positions.map(row => id(row.balanceId)))].sort(), sourcePositionIds: [...references.keys()].sort(), @@ -312,7 +392,7 @@ export function dispatchStockConservation(context, { customerId, warehouseId, di const proof = { sourcePositionBalances, totals, checks, ledgerRowChecks: rowChecks, snapshots: { beforePick: stockBefore, afterPick: stockAfter }, ledgers: { beforePick: ledgerBefore, afterPick: ledgerAfter } } if (context.createdResources?.phase5OverseasFulfillment) { - Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, proof) + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof ||= {}, proof) } for (const [name, passed] of Object.entries(checks)) { context.assert(MODULE, `本排车库存独立核对-${name}`, null, passed, JSON.stringify(totals), OPTIONS) @@ -517,14 +597,14 @@ export async function completeReturnedException(context, { customerId, warehouse id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode && Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS) if (!location?.locationCode) return null - let processingVersion = Number(order.version) + let processingVersion = version(order.version) let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : '' if (!continuing) { - const startBody = { expectedVersion: Number(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' } + const startBody = { expectedVersion: version(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' } const started = await platform(context, 'POST', '开始处理同一派送退回异常', query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody) if (!success(started.response) || started.data?.status !== 'PROCESSING') return null - processingVersion = Number(started.data.version) + processingVersion = version(started.data.version) const evidence = await context.requestMultipart('POST', query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }), { name: `${context.marker}-return-receipt.txt`, type: 'text/plain', @@ -535,7 +615,7 @@ export async function completeReturnedException(context, { customerId, warehouse } const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey) ? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE') - if (!Number.isInteger(processingVersion) || processingVersion < 0) { + if (processingVersion == null || !/^(0|[1-9]\d*)$/.test(String(processingVersion))) { blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response) return null } @@ -602,10 +682,12 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde const deliveryNo = id(delivery.deliveryNo) const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId }) const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath) - const version = Number(before.data?.version) + const documentVersion = before.data?.version + const validVersion = documentVersion != null && /^(0|[1-9]\d*)$/.test(String(documentVersion)) + && (typeof documentVersion !== 'number' || Number.isSafeInteger(documentVersion)) context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response, - Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS) - if (!success(before.response) || !Number.isInteger(version) || version < 0) return null + validVersion, `version=${documentVersion ?? 'missing'}; ${trace(before.response)}`, OPTIONS) + if (!success(before.response) || !validVersion) return null const uploadResponse = await context.requestMultipart('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), { @@ -620,7 +702,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde if (!success(uploadResponse) || !fileId) return null if (context.createdResources.phase5OverseasFulfillment) { const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= [] - receipts.push({ deliveryNo, fileId, + receipts.push({ deliveryNo, customerId, fileId, content: `${context.marker} receipt acceptance for ${deliveryNo}` }) } @@ -629,7 +711,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)), })).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0) const saveBody = { - expectedVersion: version, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)), + expectedVersion: version(documentVersion), idempotencyKey: key(context, 'SAVE-RECEIPT', deliveryNo), bolNo: before.data?.bolNo || null, documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }], } @@ -641,7 +723,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody) context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response, - savedReplay.data?.replayed === true && Number(savedReplay.data?.version) === Number(saved.data?.version), + savedReplay.data?.replayed === true && String(savedReplay.data?.version) === String(saved.data?.version), trace(savedReplay.response), OPTIONS) const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`, query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) @@ -768,18 +850,21 @@ export function relabelOutboundMarksMatch(detail, relabel) { && Number(detail?.outbound?.packageCount) === expected.length } -async function verifyRelabelDispatchOutbound(context, relabel, transferBody) { +async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId) { if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) { blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据') return } - const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照', - `${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`, - { ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') }) - const outboundNo = transferred.data?.outboundNo - if (!success(transferred.response) || !nonBlank(outboundNo)) return + const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId) + if (!await pickAll(context, token, relabel.dispatchNo)) return + await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements) + const transferred = await arrangeDispatchDelivery(context, relabel.dispatchNo) + if (!transferred) return + const customerId = id(relabel.customerId) + const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo + if (!nonBlank(outboundNo)) { blocker(context, '换标排车客户出库', '全车安排结果缺少本轮换标客户出库单'); return } const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛', - query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 })) + `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`) context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response, success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo && detail.data?.outbound?.dispatchNo === relabel.dispatchNo @@ -816,80 +901,138 @@ async function loginMerchant(context, customerId) { function fixture(context) { const dispatch = context.createdResources?.phase4OverseasDispatch const receiving = context.createdResources?.phase4OverseasPda - if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'PENDING_DEPARTURE' + if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'ACTIVE' || dispatch?.stage !== 'BEFORE_LOADING' || !nonBlank(dispatch?.dispatchNo)) return null if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED' - || !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)) return null + || !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId) + || id(dispatch.warehouseId) !== id(receiving.overseasWarehouseId)) return null return { dispatch, receiving } } export async function verifyDepartureReadiness(context, token, dispatchNo) { - const ready = await pda(context, token, 'GET', '发车前回查服务端canDepart门禁', + const ready = await pda(context, token, 'GET', '发车前回查PDA公共动作门禁', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - const dispatch = await platform(context, 'GET', '发车前独立回查实际排车状态', + const dispatch = await platform(context, 'GET', '发车前独立回查实际排车阶段', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) const passed = success(ready.response) && success(dispatch.response) - && ready.data?.dispatchNo === dispatchNo && ready.data?.status === 'COMPLETED' - && ready.data?.canDepart === true && dispatch.data?.dispatchNo === dispatchNo - && dispatch.data?.status === 'READY_TO_DEPART' - context.assert(MODULE, '发车前PDA任务完成且服务端门禁和实际排车待发均通过', ready.response, - passed, `taskStatus=${ready.data?.status || 'missing'}, canDepart=${ready.data?.canDepart}, dispatchStatus=${dispatch.data?.status || 'missing'}; ${trace(ready.response)}`, OPTIONS) + && [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo + && data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING' + && data.pickingReconciled === true && data.actions?.DEPART?.allowed === true) + && String(ready.data.recordVersion) === String(dispatch.data.recordVersion) + context.assert(MODULE, '双端同版本装车后且服务端发车门禁通过', ready.response, + passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS) + if (passed) version(ready.data.recordVersion) return passed ? ready : null } export async function pickAll(context, token, dispatchNo) { let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - const items = Array.isArray(detail.data?.items) ? detail.data.items : [] - if (!success(detail.response) || items.length === 0) { - blocker(context, 'PDA排车拣货任务', '本轮排车未生成可操作PDA托盘任务', detail.response) + if (!success(detail.response) || detail.data?.status !== 'ACTIVE' || detail.data?.stage !== 'LOADING' + || !Array.isArray(detail.data.items) || !detail.data.items.length) { + blocker(context, 'PDA排车拣货任务', '本轮排车尚未通过两级审核进入LOADING', detail.response) return false } - - for (const [itemIndex, item] of items.entries()) { - let remaining = Number(item.scheduledPackages || 0) - Number(item.pickedPackages || 0) - const positions = Array.isArray(item.positions) ? item.positions : [] - for (const [positionIndex, position] of positions.entries()) { + const items = detail.data.items + for (const [itemIndex, original] of items.entries()) { + // 每条真实命令前重新读取当前公共版本和可用来源,不从上次响应推算版本。 + detail = await pda(context, token, 'GET', '扫码前刷新公共版本及真实来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + let item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId)) + if (!success(detail.response) || !item) return false + const sources = item.returnCartons?.length ? item.returnCartons.map(carton => ({ carton })) : (item.positions || []).map(position => ({ position })) + for (const [sourceIndex, source] of sources.entries()) { + const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) if (remaining <= 0) break - const available = Number(position.availablePackages || 0) - if (available <= 0) continue + const available = source.carton ? 1 : Number(source.position.availablePackages) + if (!Number.isSafeInteger(available) || available <= 0) continue const packages = Math.min(remaining, available) - const body = { - positionId: id(position.id), packages, productQuantity: packages, - idempotencyKey: key(context, 'DISPATCH-PICK', `${itemIndex}-${positionIndex}`), - remark: `${context.marker}-phase5-dispatch-pick`, - } - const picked = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}库存位${positionIndex + 1}`, - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) + const body = { recordVersion: version(detail.data.recordVersion), + positionId: source.position ? id(source.position.positionId) : null, + cartonMark: source.carton?.cartonMark || null, packages, + productQuantity: source.carton ? Number(source.carton.productQuantity) : packages, + idempotencyKey: key(context, 'DISPATCH-PICK', `${dispatchNo}-${itemIndex}-${sourceIndex}`), + remark: `${context.marker}-phase5-dispatch-pick` } + const path = `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick` + const picked = await pda(context, token, 'POST', `PDA真实扫码托盘${itemIndex + 1}来源${sourceIndex + 1}`, path, body) if (!success(picked.response)) return false - const replay = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}同键重放`, - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) - context.assert(MODULE, `PDA拣货托盘${itemIndex + 1}重放数量稳定`, replay.response, - success(replay.response) && Number(replay.data?.pickedPackages) === Number(picked.data?.pickedPackages), - trace(replay.response), OPTIONS) - remaining -= packages + const replay = await pda(context, token, 'POST', 'PDA扫码原命令重放', path, body) + const stable = success(replay.response) && replay.data?.replayed === true + && String(replay.data.recordVersion) === String(picked.data.recordVersion) + && JSON.stringify(replay.data.result) === JSON.stringify(picked.data.result) + && JSON.stringify(replay.data.counts) === JSON.stringify(picked.data.counts) + context.assert(MODULE, 'PDA扫码重放保留原结果及版本数量', replay.response, stable, trace(replay.response), OPTIONS) + if (!stable) return false + if (itemIndex === 0 && sourceIndex === 0) { + const stale = await context.request('POST', path, { ...body, idempotencyKey: `${body.idempotencyKey}-STALE` }, token, {}) + context.addStep(MODULE, '旧公共版本扫码冲突且不得再扣库存', stale, conflict(stale), trace(stale), OPTIONS) + if (!conflict(stale)) return false + } + detail = await pda(context, token, 'GET', '扫码后重新读取公共版本与数量', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId)) + if (!success(detail.response) || !item) return false } - if (remaining !== 0) { - blocker(context, `PDA托盘${itemIndex + 1}足量库存`, - `scheduled=${item.scheduledPackages}, picked=${item.pickedPackages}, remaining=${remaining}`, detail.response) + if (Number(item.scheduledPackages) !== Number(item.pickedPackages)) { + blocker(context, `PDA托盘${itemIndex + 1}足量库存`, '实际来源不足,保留本轮已扫证据', detail.response) return false } } + const passed = detail.data?.stage === 'LOADING' && detail.data?.pickingReconciled === true + && Number(detail.data?.counts?.packageCount) > 0 + && Number(detail.data.counts.packageCount) === Number(detail.data.counts.pickedPackageCount) + context.assert(MODULE, '真实扫码核平仍停留LOADING等待显式装车完成', detail.response, passed, trace(detail.response), OPTIONS) + return passed +} - const completeBody = { idempotencyKey: key(context, 'DISPATCH-COMPLETE') } - const completed = await pda(context, token, 'POST', 'PDA确认排车拣货全部完成', - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) - context.assert(MODULE, '排车进入READY_TO_DEPART且数量守恒', completed.response, - completed.data?.status === 'READY_TO_DEPART' - && Number(completed.data?.plannedPackages) === Number(completed.data?.pickedPackages) - && Number(completed.data?.remainingPackages) === 0, - trace(completed.response), OPTIONS) - if (!success(completed.response)) return false - const replay = await pda(context, token, 'POST', 'PDA完成排车拣货同键重放', - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) - context.assert(MODULE, 'PDA完成拣货重放明确标记且结果稳定', replay.response, - replay.data?.replayed === true && replay.data?.status === 'READY_TO_DEPART', trace(replay.response), OPTIONS) - return success(replay.response) +export async function arrangeDispatchDelivery(context, dispatchNo) { + const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}` + const preview = await platform(context, 'GET', '装车后读取全车真实派送分组与费用证据', `${path}/delivery-arrangement`) + const value = preview.data + if (!success(preview.response) || value?.dispatchNo !== dispatchNo || value.status !== 'ACTIVE' + || value.stage !== 'AFTER_LOADING' || value.arrange?.allowed !== true + || !nonBlank(value.cargoHash) || !nonBlank(value.loadingCompletionHash) + || !value.groups?.length || value.groups.some(group => !nonBlank(group.groupKey) + || !nonBlank(id(group.customerId)) || !nonBlank(group.destination?.key) + || !group.cargo?.length || !Array.isArray(group.blockReasons) || group.blockReasons.length)) { + blocker(context, '全车派送安排', '装车后正式预览或完整客户目的地分组门禁未满足', preview.response) + return null + } + const body = { recordVersion: version(value.recordVersion), idempotencyKey: key(context, 'TRANSFER', dispatchNo), + cargoHash: value.cargoHash, loadingCompletionHash: value.loadingCompletionHash, + groups: value.groups.map((group, index) => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, + plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), + deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-${index + 1}`.slice(0, 64), + carrierName: '本轮TEST排车', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', + vehicleType: 'TRUCK', vehicleSize: '按本轮真实实载', appointmentAccount: 'SELF', sellerName: null, + remark: `${context.marker}-phase5-arrangement`, externalFreightHash: group.externalFreightHash, + feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], + fees: (group.requiredServices || []).map(service => ({ feeType: service, feeName: service, + direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-controlled-test-fee` })) })) } + const transferred = await platform(context, 'POST', '平台一次安排全车全部客户目的地派送', `${path}/transfer-delivery`, body) + if (!success(transferred.response)) return null + const result = transferred.data?.result + const deliveries = transferDeliveries(transferred.data) + const outbounds = result?.outbounds + const customerGroups = value.groups.map(group => `${id(group.customerId)}:${group.destination.key}`).sort() + const actualGroups = deliveries.map(row => `${id(row.customerId)}:${row.destination?.key}`).sort() + const passed = transferred.data?.dispatchNo === dispatchNo && transferred.data?.status === 'ACTIVE' + && transferred.data?.stage === 'AFTER_LOADING' && result?.dispatchNo === dispatchNo + && result.cargoHash === body.cargoHash && deliveries.length === value.groups.length + && new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length + && JSON.stringify(customerGroups) === JSON.stringify(actualGroups) + && Array.isArray(outbounds) && outbounds.length === new Set(value.groups.map(group => id(group.customerId))).size + && outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'WAIT_OUTBOUND' + && deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).every(delivery => id(delivery.customerId) === id(row.customerId)) + && deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount)) + && deliveries.every(row => row.status === 'WAIT_DELIVERY') + context.assert(MODULE, '派送按客户出库及客户目的地完整分组并数量守恒', transferred.response, passed, trace(transferred.response), OPTIONS) + if (!passed) return null + const replay = await platform(context, 'POST', '全车安排派送原命令重放', `${path}/transfer-delivery`, body) + const stable = success(replay.response) && replay.data?.replayed === true + && String(replay.data.recordVersion) === String(transferred.data.recordVersion) + && JSON.stringify(replay.data.result) === JSON.stringify(result) + context.assert(MODULE, '安排重放返回原命令结果且不增单据', replay.response, stable, trace(replay.response), OPTIONS) + if (!stable) return null + return { ...transferred, preview: value, outbounds, deliveries } } /** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */ @@ -947,120 +1090,23 @@ export async function run(context) { const merchantToken = await loginMerchant(context, customerId) if (!merchantToken) return - const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + context = await fulfillmentEvidenceContext(context) + const beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = { - frozenPlanPalletIds:source.dispatch.planPalletIds, - frozenDestinationWarehouseCodes:source.dispatch.destinationWarehouseCodes, - beforeEdit:beforeEdit.data, - } - context.assert(MODULE,'排车线路目的地集合与实际托盘及本轮冻结候选完全一致',beforeEdit.response, - dispatchDestinationConservation(beforeEdit.data,source.dispatch),trace(beforeEdit.response),OPTIONS) + sourceKeys: source.dispatch.sourceKeys, frozenDestinationWarehouseCodes: source.dispatch.destinationWarehouseCodes, + beforeEdit: beforeEdit.data } + context.assert(MODULE, '排车来源和目的地与本轮生产者守恒', beforeEdit.response, + dispatchDestinationConservation(beforeEdit.data,source.dispatch), trace(beforeEdit.response), OPTIONS) if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch)) return - const updateBody = { - vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64), - driverName: `五期验收司机-${context.marker.slice(-6)}`, - driverPhone: '19900000000', carrierName: '五期开发验收车队', - estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(), - } - const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路', - `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody) - context.assert(MODULE, '排车编辑字段完整回显', updated.response, - updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate - && updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route, - trace(updated.response), OPTIONS) - if (!success(updated.response)) return - + const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol, bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') && new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS) - - const transferBody = { - idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), - deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64), - carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', - vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName, - driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate, - appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`, - fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', - currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }], - } - await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody) - const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', - query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) - const outboundNo = id(transferred.data?.outboundNo) - const deliveries = transferDeliveries(transferred.data) - const deliveryNos = deliveryNumbers(transferred.data) - const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId)) - routeProof.deliveries = deliveries - context.assert(MODULE,'转派送目的地集合与实际排车线路及本轮冻结候选守恒',transferred.response, - dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries),trace(transferred.response),OPTIONS) - if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return - const deliveryNo = deliveryNos[0] || '' - context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response, - nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length - && new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank) - && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' - && deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY') - && deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount) - && Number(transferred.data?.packageCount) > 0, - `outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS) - if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return - Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos }) - const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单', - `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody) - context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response, - adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo - && JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS) - const lockedItem = updated.data?.items?.[0] - if (lockedItem?.id) { - const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) - context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS) - const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response, - Number(stable.data?.packageCount) === Number(updated.data?.packageCount) - && JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS) - } else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID') - const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放', - query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) - context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, - transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo - && JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS) - const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {}) - context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict, - success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo - && transferConflict.body?.data?.replayed === true - && JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS) - - const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', - query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo })) - const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : [] - context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response, - deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number - && id(value?.dispatchNo) === dispatchNo)), - `records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS) - const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', - `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`) - context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response, - id(merchantDetail.data?.order?.deliveryNo) === deliveryNo - && id(merchantDetail.data?.order?.dispatchNo) === dispatchNo - && nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode - || merchantDetail.data?.order?.destinationWarehouseId)) - && Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0 - && Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), - `skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`, - OPTIONS) - const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 5, warehouseId, access) : null - const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))] - let tripProof - if (dispatchInboundCodes.length === 0) { - blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码') - await disablePicker(context, picker, 5) - return - } + let deliveries, outbounds, transferred try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') @@ -1068,90 +1114,117 @@ export async function run(context) { } const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - const sourceIds = dispatchInventorySources(sourceTask.data, dispatchNo, warehouseId) - context.assert(MODULE, '真实排车任务冻结全部库存位置及余额ID', sourceTask.response, - success(sourceTask.response) && Boolean(sourceIds), `dispatchNo=${dispatchNo}`, OPTIONS) - if (!sourceIds) return - const { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } = sourceIds - context.createdResources.phase5OverseasFulfillment.inventoryProof = { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } - const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存', sourceBalanceIds) - const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水', 'SHIP', dispatchNo) - Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { snapshots: { beforePick: stockBeforePick }, ledgers: { beforePick: ledgerBeforePick } }) - const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances) - context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS) - if (!sourcesBeforeValid || !ledgerBeforePick) return - tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) - const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo) - const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds) - const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo) - const expectedPackages = Number(transferred.data.packageCount) - const conservation = dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, - sourcePositionBalances, stockBefore: stockBeforePick, stockAfter: stockAfterPick, - ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick }) - const shippedRows = conservation.newLedger - if (!pickedSuccessfully) return - const tripVersion = await confirmFleetLoading(context, MODULE, tripProof) + if (!success(sourceTask.response) || !sourceTask.data?.items?.length) return + const customerIds = [...new Set(sourceTask.data.items.map(item => id(item.customerId)))] + const detailCustomers = [...new Set(beforeEdit.data.orderDetails.map(row => id(row.customerId)))].sort() + if (!customerIds.every(nonBlank) || JSON.stringify([...customerIds].sort()) !== JSON.stringify(detailCustomers)) { + blocker(context, '整车真实客户集合', 'PDA客户与本轮原生详情不一致'); return + } + const proofs = context.createdResources.phase5OverseasFulfillment.inventoryByCustomer = [] + for (const currentCustomer of customerIds) { + const task = { ...sourceTask.data, items: sourceTask.data.items.filter(item => id(item.customerId) === currentCustomer) } + const sources = dispatchInventorySources(task, dispatchNo, warehouseId) + const inboundCodes = [...new Set(beforeEdit.data.orderDetails.filter(row => id(row.customerId) === currentCustomer) + .map(row => row.inboundCode).filter(nonBlank))] + if (!sources || !inboundCodes.length) { blocker(context, '整车客户库存生产证据', '缺少当前客户真实库存位置或入仓码'); return } + const stockBefore = await inventorySnapshot(context, currentCustomer, warehouseId, inboundCodes, `客户${currentCustomer}扫码前库存`, sources.sourceBalanceIds) + const ledgerBefore = await shipmentLedger(context, currentCustomer, dispatchNo, `客户${currentCustomer}扫码前SHIP`, 'SHIP', dispatchNo) + if (!stockBefore || !ledgerBefore || !inventorySourcesPresent(stockBefore, sources.sourcePositionBalances)) return + proofs.push({ customerId: currentCustomer, inboundCodes, ...sources, stockBefore, ledgerBefore, + expectedPackages: task.items.reduce((sum, row) => sum + Number(row.scheduledPackages), 0) }) + } + await context.checkpoint() + if (!await pickAll(context, picker.token, dispatchNo)) return + for (const proof of proofs) { + proof.stockAfter = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes, + `客户${proof.customerId}扫码后库存`, proof.sourceBalanceIds) + proof.ledgerAfter = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}扫码后SHIP`, 'SHIP', dispatchNo) + const conservation = dispatchStockConservation(context, { ...proof, warehouseId, dispatchNo }) + proof.conservation = conservation.proof + if (!conservation.passed) return + } + const primaryInventory = proofs.find(proof => proof.customerId === customerId) + if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation } + await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements) + transferred = await arrangeDispatchDelivery(context, dispatchNo) + if (!transferred) return + deliveries = transferred.deliveries; outbounds = transferred.outbounds + routeProof.deliveries = deliveries + context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response, + dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS) + if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) if (!ready) return - const departBody = { recordVersion: tripVersion, idempotencyKey: key(context, 'DISPATCH-DEPART').slice(0, 128), remark: `${context.marker}-depart` } - const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', + const departBody = { recordVersion: version(ready.data.recordVersion), + idempotencyKey: key(context, 'DISPATCH-DEPART', dispatchNo).slice(0, 128), remark: `${context.marker}-depart` } + const departed = await pda(context, picker.token, 'POST', 'PDA实际发车并完成真实出库', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) - context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response, - departed.data?.status === 'IN_TRANSIT' && departed.data?.tripNo === tripProof.tripNo - && departed.data?.dispatches?.some(item => item.dispatchNo === dispatchNo) && Number(departed.data?.shippedPackages) > 0, - trace(departed.response), OPTIONS) - if (!success(departed.response)) return - const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放', + const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0) + const departedCorrectly = success(departed.response) && departed.data?.dispatchNo === dispatchNo + && departed.data.status === 'ACTIVE' && departed.data.stage === 'DELIVERING' + && Number(departed.data.result?.shippedPackages) === expectedPackages + && JSON.stringify(deliveryNumbers(departed.data)) === JSON.stringify(deliveries.map(row => row.deliveryNo).sort()) + context.assert(MODULE, '实际发车进入DELIVERING且全部客户实发数量守恒', departed.response, departedCorrectly, trace(departed.response), OPTIONS) + if (!departedCorrectly) return + const departReplay = await pda(context, picker.token, 'POST', 'PDA发车原命令重放', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) - context.assert(MODULE, 'PDA发车重放明确标记且发运数量稳定', departReplay.response, - departReplay.data?.replayed === true - && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), - trace(departReplay.response), OPTIONS) - const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds) - const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水', 'SHIP', dispatchNo) - const inventoryProof = context.createdResources.phase5OverseasFulfillment.inventoryProof - inventoryProof.snapshots.afterDepart = stockAfterDepart - inventoryProof.ledgers.afterDepart = ledgerAfterDepart - context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, - inventorySourcesPresent(stockAfterDepart, sourcePositionBalances) - && sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) - Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { - sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: conservation.proof.totals.positionBefore, - afterPackages: quantityTotal((stockAfterDepart || []).flatMap(row => row.positions.filter(position => sourcePositionBalances.some(reference => reference.positionId === position.id && reference.balanceId === position.balanceId)))), expectedPackages, - shipmentLedgerIds: (shippedRows || []).map(row => row.id), - canDepart: ready.data?.canDepart === true, - }) + const replayStable = success(departReplay.response) && departReplay.data?.replayed === true + && String(departReplay.data.recordVersion) === String(departed.data.recordVersion) + && JSON.stringify(departReplay.data.result) === JSON.stringify(departed.data.result) + context.assert(MODULE, 'PDA发车重放保持原结果及公共版本', departReplay.response, replayStable, trace(departReplay.response), OPTIONS) + if (!replayStable) return + for (const proof of proofs) { + proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes, + `客户${proof.customerId}发车后库存`, proof.sourceBalanceIds) + proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo) + const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances) + && sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart) + context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', departReplay.response, unchanged, trace(departReplay.response), OPTIONS) + if (!unchanged) return + } + // 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。 + await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, picker.token, warehouseId) } finally { - await disablePicker(context, picker, 5) - if (picker) picker.token = null + try { await disablePicker(context, picker, 5) } + finally { if (picker) picker.token = null; await context.checkpoint() } } + const primaryDelivery = deliveries.find(row => id(row.customerId) === customerId) + if (!primaryDelivery) { blocker(context, '客户签收资料前置', '本轮主客户不在实际派送结果中'); return } + const deliveryNo = primaryDelivery.deliveryNo, outboundNo = primaryDelivery.outboundNo + const deliveryNos = deliveries.map(row => row.deliveryNo).sort() + Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos, outbounds, deliveries }) + for (const outboundRef of outbounds) { + const outbound = await platform(context, 'GET', '逐客户回查发车后真实出库状态', `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundRef.outboundNo)}`) + const passed = outbound.data?.outbound?.outboundNo === outboundRef.outboundNo + && id(outbound.data?.outbound?.customerId) === id(outboundRef.customerId) && outbound.data?.outbound?.status === 'OUTBOUNDED' + context.assert(MODULE, '每客户出库单独立OUTBOUNDED且归属不漂移', outbound.response, passed, trace(outbound.response), OPTIONS) + if (!passed) return + } + const customerDeliveries = deliveries.filter(row => id(row.customerId) === customerId) + const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', query(MERCHANT_DELIVERY_BASE, { page: 1, size: 200, dispatchNo })) + const merchantRows = merchantPage.data?.records + const merchantScoped = Array.isArray(merchantRows) + && customerDeliveries.every(delivery => merchantRows.some(row => row.deliveryNo === delivery.deliveryNo)) + && merchantRows.every(row => id(row.customerId) === customerId) + context.assert(MODULE, '客户只看本车自己的派送单', merchantPage.response, merchantScoped, trace(merchantPage.response), OPTIONS) + if (!merchantScoped) return + const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`) + context.assert(MODULE, '客户派送详情保留订单货物费用操作真实结构', merchantDetail.response, + merchantDetail.data?.order?.deliveryNo === deliveryNo && Array.isArray(merchantDetail.data?.skus) + && Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), trace(merchantDetail.response), OPTIONS) - const outbound = await platform(context, 'GET', '回查发车后出库单状态', - query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 20 })) - context.assert(MODULE, '排车PDA发车同步出库单OUTBOUNDED', outbound.response, - outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED', - trace(outbound.response), OPTIONS) - - for (const [index, delivery] of deliveries.entries()) { - const currentNo = id(delivery.deliveryNo) - const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`, - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId })) - const deliveryVersion = Number(beforeStatus.data?.order?.version) - context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response, - Number.isInteger(deliveryVersion) && deliveryVersion >= 0, - `version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS) - if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return - const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`, - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }), - { status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` }) - context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response, - delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) - if (!success(delivering.response)) return + for (const delivery of deliveries) { + const current = await platform(context, 'GET', '逐客户核对发车后真实派送状态', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}`, { customerId: id(delivery.customerId) })) + const passed = current.data?.order?.status === 'DELIVERING' + && id(current.data.order.customerId) === id(delivery.customerId) && current.data.order.outboundNo === delivery.outboundNo + context.assert(MODULE, '派送由实际发车进入DELIVERING并保留客户出库关联', current.response, passed, trace(current.response), OPTIONS) + if (!passed) return } const completedDeliveries = [] for (const [index, delivery] of deliveries.entries()) { - const completed = await saveReceiptAndComplete(context, delivery, customerId, index) + const completed = await saveReceiptAndComplete(context, delivery, id(delivery.customerId), index) if (!completed) return completedDeliveries.push(completed) } @@ -1160,6 +1233,12 @@ export async function run(context) { && completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'), `deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS) + const normalCompletion = await poll(() => context.request('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`), + response => success(response) && response.body?.data?.status === 'ACTIVE' && response.body?.data?.stage === 'DELIVERED') + context.assert(MODULE, '全部客户正常签收汇总整车DELIVERED', normalCompletion.result, + success(normalCompletion.result) && normalCompletion.result.body?.data?.stage === 'DELIVERED', trace(normalCompletion.result), OPTIONS) + if (normalCompletion.result?.body?.data?.stage !== 'DELIVERED') return + context.createdResources.phase5OverseasFulfillment.normalCompletion = normalCompletion.result.body.data const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete, removedComplete?.http === 404 || removedComplete?.http === 405 @@ -1201,11 +1280,12 @@ export async function run(context) { return } context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId - await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken }) + const rootCompletion = await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken }) + if (!rootCompletion?.passed) return const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken || await createSignedDocumentIsolationCustomer(context) - await verifyCustomerSignedDocuments(context, merchantToken, - context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken) + if (!await verifyCustomerSignedDocuments(context, merchantToken, + context.createdResources.phase5OverseasFulfillment.receiptFiles?.filter(receipt => receipt.customerId === customerId), foreignMerchantToken)) return const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { @@ -1216,6 +1296,19 @@ export async function run(context) { savedVisit.data?.visit?.status === 'COMPLETED' && savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode && savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS) + if (!success(savedVisit.response) || savedVisit.data?.visit?.status !== 'COMPLETED' + || savedVisit.data.visit.selectedInboundCode !== inbound.inboundCode + || savedVisit.data.visit.visitResult !== 'SATISFIED') return + + // 默认验收在正常签收与回访完成后结案。旧异常子场景仅接受调用方明确授权的独立开关。 + if (context.context?.phase5TailVerification?.runReturnScenario !== true) { + context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) + await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo], + businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true }) + context.createdResources.phase5OverseasFulfillment.finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT' + await context.checkpoint() + return + } const manualExceptionBody = { customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo, @@ -1223,10 +1316,6 @@ export async function run(context) { } let manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常', `${LASTLEG_BASE}/exception-orders`, manualExceptionBody) - if (manualException.response.http === 0) { - manualException = await platform(context, 'POST', '人工异常连接失败后使用同载荷重试', - `${LASTLEG_BASE}/exception-orders`, manualExceptionBody) - } const manualExceptionNo = id(manualException.data?.exceptionNo) context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response, nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS) @@ -1264,7 +1353,7 @@ export async function run(context) { context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo], - businesses: deliveryNos.map(deliveryNo => ({ customerId, deliveryNo })), receiptsVisible: true }) + businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true }) context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' } diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index 413c509df..00d869942 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test' import { readFileSync } from 'node:fs' import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate, completionCounts, exactCompletionOutboxProof, rootCompletionTimeMatches, resolveF40MerchantCredentials, - f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder, + f40CreateDispatchBody, resolveF40LoadingMeasurements, sendFragment, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder, validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder, verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, resolveF40ReceivingWarehouse, run } from './phase5-root-fulfillment.mjs' @@ -153,24 +153,52 @@ test('F40 retains partial and failed receiving raw snapshots without exporting i assert.match(source,/proof.receiptReplayProofs \|\|= \[\]/) }) -test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => { - const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8') - const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'} - const now=new Date('2026-09-30T01:02:03.456Z'),body=f40TransferBody('offline',1,vehicle,now) - for(const field of ['idempotencyKey','plannedDeliveryAt','deliveryMethod','pickupMethod','loadingType','fees']) { - assert.match(dto,new RegExp(`\\b${field}\\b`));assert.ok(body[field]) - } - assert.equal(body.plannedDeliveryAt,'2026-10-02T01:02:03') - assert.equal(f40LocalDateTime(now),'2026-09-30T01:02:03') - assert.equal(body.deliveryMethod,'TRUCK_PALLET');assert.equal(body.pickupMethod,'WAREHOUSE_PICKUP') - assert.equal(body.loadingType,'PALLET');assert.equal(body.vehicleSize,'15托') - for(const field of Object.keys(vehicle))assert.equal(body[field],vehicle[field]) - assert.equal(body.fees.length,1) - assert.deepEqual(Object.fromEntries(['feeType','feeName','direction','currency','amount'].map(field=>[field,body.fees[0][field]])), - {feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10}) - assert.match(source,/f40TransferBody\(context.marker,index,vehicle\)/) - assert.match(source,/estimatedArrivalAt: f40LocalDateTime\(/) +function nativeF40Candidate() { + const sourceVersion = 'a'.repeat(64) + return { sourceKey: 'PLAN:9007199254740993', sourceVersion, customerId: '90', warehouseId: '80', + inboundCode: 'IN-F40', availablePackages: '1', destination: { key: 'FBA:ONT8', type: 'FBA', warehouseCode: 'ONT8' }, + selection: { sourceType: 'INBOUND_PLAN', planPalletId: '9007199254740993', warehousePalletId: '9007199254740994', + returnReceiptId: null, relabelNo: null, sourceVersion }, blockReasons: [], appointmentBatches: [{ + batchKey: 'FBA_APPOINTMENT:9007199254740995', customerId: '90', recordVersion: '9007199254740996', + sources: [{ sourceKey: 'ORDER:9007199254740997', destinationKey: 'FBA:ONT8', packageCount: 1 }] }] } +} + +test('F40 native Create uses frozen candidate source and exact FBA batch version including zero, without legacy vehicle/Transfer fields', () => { + const candidate = nativeF40Candidate() + const body = f40CreateDispatchBody('offline', '80', '90', [candidate]) + assert.equal(body.warehouseId, '80') + assert.equal(body.cargo[0].planPalletId, '9007199254740993') + assert.equal(body.cargo[0].warehousePalletId, '9007199254740994') + assert.equal(body.cargo[0].sourceVersion, candidate.sourceVersion) + assert.deepEqual(body.batchAllocations, [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:9007199254740995', + customerId: '90', sourceKey: 'ORDER:9007199254740997', destinationKey: 'FBA:ONT8', packageCount: 1, recordVersion: '9007199254740996' }]) + candidate.appointmentBatches[0].recordVersion = '0' + assert.equal(f40CreateDispatchBody('offline', '80', '90', [candidate]).batchAllocations[0].recordVersion, '0') + assert.equal(body.vehicle, null); assert.equal(body.driver, null) + assert.equal('vehiclePlate' in body, false); assert.equal('plannedDeliveryAt' in body, false) + assert.equal(f40LocalDateTime(new Date('2026-09-30T01:02:03.456Z')), '2026-09-30T01:02:03') + const dto = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java', import.meta.url), 'utf8') + assert.match(dto, /record Create/) + for (const field of ['warehouseId', 'cargo', 'batchAllocations', 'sourceVersion']) assert.ok(dto.includes(field), field) }) + +test('F40 native Create rejects foreign/ambiguous sources and incomplete batch allocation instead of guessing source or version', () => { + for (const mutate of [value => { value.customerId = '91' }, value => { value.warehouseId = '81' }, + value => { value.sourceVersion = 'stale' }, value => { value.selection.sourceVersion = 'stale' }, + value => { value.selection.planPalletId = 9007199254740992 }, value => { value.availablePackages = '9007199254740993' }, + value => { value.blockReasons = ['NOT_RECEIVED'] }, value => { value.appointmentBatches[0].recordVersion = null }, + value => { value.appointmentBatches[0].recordVersion = 9007199254740992 }, + value => { value.appointmentBatches[0].customerId = '91' }, + value => { value.appointmentBatches[0].sources[0].packageCount = 2 }, + value => { value.appointmentBatches[0].sources[0].destinationKey = 'FBA:OTHER' }, + value => { value.appointmentBatches.push(structuredClone(value.appointmentBatches[0])) }]) { + const candidate = nativeF40Candidate(); mutate(candidate) + assert.throws(() => f40CreateDispatchBody('offline', '80', '90', [candidate])) + } + const candidate = nativeF40Candidate() + assert.throws(() => f40CreateDispatchBody('offline', '80', '90', [candidate, candidate])) +}) + test('F40 credentials prefer a complete current fixture pair and never combine fixture with configured customer',() => { const options={username:'offline-admin',password:'offline-admin-secret',merchantUsername:'configured-customer',merchantPassword:'configured-secret'} const current={username:'fixture-customer',password:'fixture-secret',tenantId:'90'} @@ -537,3 +565,130 @@ test('early F40 failures become FAILED with only a safe stage and preserve the o assert.equal(withLogin.createdResources.phase5RootFulfillment.finalStatus,'FAILED') assert.equal(JSON.stringify(withLogin.createdResources.phase5RootFulfillment).includes(secretError.message),false) }) + + +function nativeFragmentFixture() { + const context = fixture(), events = [], calls = [] + const producer = { tenantId: '90', inboundCode: 'IN-F40', sourceInboundId: '901', sourceOrderId: '100', + rootOrderId: '100', containerBookingId: '902' } + const rootProof = { fragments: [producer], currentStage: 'DISPATCH_AND_SIGNATURE_1' } + context.context = { f40BaseMarker: 'offline-F40', f40RootFulfillmentProof: rootProof } + const measurement = { environment: 'TEST', marker: 'offline-F40-SEND-0', dispatchNo: 'DS-F40', pallets: [] } + context.context.f40LoadingMeasurements = { 'DS-F40': measurement } + context.checkpoint = async () => { events.push('checkpoint') } + const delivery = { customerId: '90', deliveryNo: 'DL-F40', outboundNo: 'OB-F40', packageCount: '1', + destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } } + let created, departed, signed = false + context.request = async (method, path, body, token) => { + calls.push({ method, path, body: structuredClone(body), token }) + let data + if (method === 'GET' && path.includes('/candidates?')) { + assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('warehouseId'), '80') + assert.equal(new URL(path, 'https://offline.invalid').searchParams.has('sourceStage'), false) + data = { complete: true, candidates: [nativeF40Candidate()] } + } else if (method === 'POST' && path.endsWith('/dispatches')) { + events.push('create') + if (created) data = { ...created, replayed: true } + else data = created = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0', + result: { dispatchNo: 'DS-F40', changedItemIds: ['9007199254740998'] } } + } else if (method === 'POST' && path.endsWith('/depart')) { + events.push('depart') + assert.equal(token, 'pda-memory') + assert.equal(body.recordVersion, '9007199254740999') + if (departed) { + assert.deepEqual(body, calls.find(call => call.path.endsWith('/depart')).body) + data = { ...departed, replayed: true } + } else data = departed = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '9007199254741000', + result: { shippedPackages: '1', deliveries: [delivery], outbounds: [{ customerId: '90', outboundNo: 'OB-F40' }] } } + } else if (method === 'GET' && path.includes('/delivery-orders/')) { + data = { order: { id: '9007199254741001', customerId: '90', outboundNo: 'OB-F40', deliveryNo: 'DL-F40', + version: '9007199254741002', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY' }, skus: [{ inboundCode: 'IN-F40', packageCount: 1 }] } + } else if (method === 'GET' && path.endsWith('/outbound-orders/OB-F40')) { + data = { outbound: { customerId: '90', outboundNo: 'OB-F40', status: 'OUTBOUNDED' }, pallets: [{ inboundCode: 'IN-F40' }] } + } else if (method === 'GET' && path.endsWith('/dispatches/DS-F40')) { + assert.equal(signed, true); events.push('normal-completed') + data = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'DELIVERED', recordVersion: '9007199254741004' } + } else throw new Error(`Unexpected offline wire ${method} ${path}`) + return { http: 200, body: { code: '200', data } } + } + const deps = { + async fulfillmentEvidenceContext(current) { events.push('evidence'); return current }, + async prepareFleetTrip(current, module, dispatchNo, warehouseId) { + events.push('prepare-review'); assert.equal(dispatchNo, 'DS-F40'); assert.equal(warehouseId, '80') + return { dispatchNo, warehouseId, tripNo: 'TR-F40', recordVersion: '1' } + }, + async pickAll(current, token, dispatchNo) { events.push('pick'); assert.equal(token, 'pda-memory'); assert.equal(dispatchNo, 'DS-F40'); return true }, + async confirmFleetLoading(current, module, proof, token, actual) { + events.push('loading-complete'); assert.equal(token, 'pda-memory'); assert.equal(actual, measurement) + if (!actual) throw new Error('实际测量缺少有效palletSpaces') + return '9007199254740998' + }, + async arrangeDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery], + outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { result: { deliveries: [delivery] } } } }, + async verifyDepartureReadiness() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999' } } }, + async saveReceiptAndComplete(current, actual, customerId, index) { + events.push('receipt'); assert.equal(actual, delivery); assert.equal(customerId, '90'); assert.equal(index, 0) + signed = true + return { order: { customerId: '90', deliveryNo: 'DL-F40', status: 'DELIVERED', version: '9007199254741003', signedAt: '2026-10-19T01:00:00' } } + }, + async finishFleetTrip() { events.push('finish'); return { status: 'VERIFIED' } }, + } + return { context, producer, rootProof, deps, events, calls, measurement } +} + +test('F40 fragment uses native creation, reviews, real PDA scan, explicit loading/token/evidence, arrangement, refreshed departure version and normal signature', async () => { + const f = nativeFragmentFixture() + const result = await sendFragment(f.context, f.producer, { warehouseId: '80', token: 'pda-memory' }, 'merchant-memory', 0, f.deps) + assert.equal(result.outboundNo, 'OB-F40'); assert.equal(result.receiptVersion, '9007199254741003') + assert.equal(result.signedAt, '2026-10-19T01:00:00') + assert.deepEqual(f.events.filter(event => event !== 'checkpoint'), ['evidence', 'create', 'create', 'prepare-review', 'pick', + 'loading-complete', 'arrange', 'read-current-depart-version', 'depart', 'depart', 'receipt', 'normal-completed', 'finish']) + assert.equal(f.calls.some(call => /pre-arrange|\/status|\/api\/merchant\//.test(call.path)), false) + assert.equal(f.calls.some(call => call.method === 'PUT'), false) + assert.equal(f.rootProof.dispatches[0].stage, 'DELIVERED') + assert.equal(f.rootProof.dispatches[0].departureReplay.replayed, true) + assert.equal(f.context.steps.every(step => step.passed), true) +}) + +test('F40 missing real measurement evidence stops before arranging, departing or signing while keeping the failed source proof', async () => { + const f = nativeFragmentFixture() + delete f.context.context.f40LoadingMeasurements + f.deps.confirmFleetLoading = async (_context, _module, _proof, token, actual) => { + assert.equal(token, 'pda-memory'); assert.equal(actual, undefined) + throw new Error('实际测量缺少有效palletSpaces') + } + await assert.rejects(sendFragment(f.context, f.producer, { warehouseId: '80', token: 'pda-memory' }, 'merchant-memory', 0, f.deps), /实际测量缺少/) + assert.equal(f.events.includes('arrange'), false) + assert.equal(f.calls.some(call => call.path.endsWith('/depart')), false) + assert.equal(f.events.includes('receipt'), false) + assert.equal(f.rootProof.dispatches[0].dispatchNo, 'DS-F40') + assert.equal(f.rootProof.dispatches[0].stage, 'FAILED') + assert.equal(f.rootProof.dispatches[0].failedAction, 'LOADING_COMPLETE') + assert.equal(f.events.at(-1), 'checkpoint') +}) + +test('F40 measurement reader receives the exact current TEST source scope and preserves its returned evidence without inferred values', async () => { + const f = nativeFragmentFixture(), response = { arbitrary: 'opaque-original-proof' } + f.context.marker = 'offline-F40-SEND-0' + f.context.context.f40LoadingMeasurements = async scope => { + assert.deepEqual(scope, { environment: 'TEST', marker: 'offline-F40-SEND-0', dispatchNo: 'DS-F40', + warehouseId: '80', sourceInboundId: '901', inboundCode: 'IN-F40' }) + return response + } + assert.equal(await resolveF40LoadingMeasurements(f.context, 'DS-F40', f.producer, '80'), response) +}) + +test('F40 fresh receipt revision evidence preserves string versions above safe integer precision', () => { + const oldVersion = '9007199254740993', newVersion = '9007199254740994' + const documents = { version: oldVersion, uploadedCategoryCount: 1, categories: [{ category: 'RECEIPT', files: [{ fileId: '9007199254740995' }] }], replayed: false } + const before = { documents, delivery: { order: { version: oldVersion, status: 'DELIVERED', signedAt: '2026-10-19T01:00:00' }, + operations: [{ id: '9007199254740996', operationNode: 'RECEIPT_AUTO_COMPLETE' }] }, revisions: [{ version: oldVersion }] } + const after = structuredClone(before) + after.documents.version = newVersion; after.delivery.order.version = newVersion + after.delivery.operations.push({ id: '9007199254740997', operationNode: 'SAVE_DOCUMENTS', result: '保存派送资料:1/3类', + operatedAt: '2026-10-19T01:01:00', operatorName: 'TEST' }) + after.revisions.push({ version: newVersion, savedAt: '2026-10-19T01:01:00', operatorId: 'platform', snapshot: after.documents }) + assert.equal(validFreshReceiptSave(before, after), true) + after.documents.version = '9007199254740995' + assert.equal(validFreshReceiptSave(before, after), false) +}) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 7ed2caa47..57d3ef7bc 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -4,7 +4,7 @@ import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { stableLoadedCartonMarks } from './phase3-loading.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' -import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' +import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' import { isDeepStrictEqual } from 'node:util' import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs' import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' @@ -15,7 +15,17 @@ const MODULE = 'phase5-root-fulfillment' const OPTIONS = { scenario: 'f40-real-root-and-remainder-signatures', deploymentBlocker: true } const DISPATCH = '/api/platform/v1/last-leg/dispatches' const PDA = '/api/pda/v1/last-leg/dispatches' -const id = value => value == null ? '' : String(value) +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('F40业务ID精度无效') + return value == null ? '' : String(value) +} +const validVersion = value => (typeof value === 'string' || typeof value === 'number' && Number.isSafeInteger(value)) + && /^(0|[1-9]\d*)$/.test(String(value)) +const quantity = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : null +const wireVersion = value => { + if (!validVersion(value)) throw new Error('F40服务器版本缺失或精度无效') + return value +} const success = response => response?.http === 200 && String(response?.body?.code) === '200' const query = (path, values) => `${path}?${new URLSearchParams(values)}` @@ -66,11 +76,12 @@ export function preserveReceivingEvidence(proof, scope) { proof.receivingCandidateEvidence = scope.createdResources?.phase4OverseasPda?.candidateWaitEvidence || [] } export function validFreshReceiptSave(before, after) { - const previousVersion = Number(before?.documents?.version) - const nextVersion = Number(after?.documents?.version) - if (!Number.isSafeInteger(previousVersion) || nextVersion !== previousVersion + 1 - || Number(before?.delivery?.order?.version) !== previousVersion - || Number(after?.delivery?.order?.version) !== nextVersion + if (![before?.documents?.version, after?.documents?.version, before?.delivery?.order?.version, after?.delivery?.order?.version].every(validVersion)) return false + const previousVersion = BigInt(before.documents.version) + const nextVersion = BigInt(after.documents.version) + if (nextVersion !== previousVersion + 1n + || BigInt(before.delivery.order.version) !== previousVersion + || BigInt(after.delivery.order.version) !== nextVersion || before?.delivery?.order?.status !== 'DELIVERED' || !before.delivery.order.signedAt) return false const same = (left,right) => JSON.stringify(left) === JSON.stringify(right) if (!same(before.documents,{...after.documents,version:before.documents.version})) return false @@ -87,9 +98,9 @@ export function validFreshReceiptSave(before, after) { operations:oldOperations})) return false if (!Array.isArray(before.revisions) || !Array.isArray(after.revisions) || after.revisions.length !== before.revisions.length + 1) return false - const addedRevisions = after.revisions.filter(row => Number(row.version) === nextVersion) + const addedRevisions = after.revisions.filter(row => validVersion(row.version) && BigInt(row.version) === nextVersion) return addedRevisions.length === 1 && Boolean(addedRevisions[0].savedAt && addedRevisions[0].operatorId) - && same(before.revisions,after.revisions.filter(row => Number(row.version) !== nextVersion)) + && same(before.revisions,after.revisions.filter(row => !validVersion(row.version) || BigInt(row.version) !== nextVersion)) && same(addedRevisions[0].snapshot,after.documents) } export function resolveF40MerchantCredentials(context) { @@ -102,18 +113,48 @@ export function resolveF40MerchantCredentials(context) { if (!fixture.username || !fixture.password || !id(fixture.tenantId)) return null return { username:fixture.username, password:fixture.password, customerId:id(fixture.tenantId) } } -export function f40TransferBody(marker, index, vehicle, now = new Date()) { - return { - idempotencyKey:`${marker}-F40-TRANSFER-${index}`, - plannedDeliveryAt:f40LocalDateTime(new Date(now.getTime()+172800000)), - deliveryMethod:'TRUCK_PALLET', waybillNo:`${marker}-WAYBILL-${index}`.slice(0,64), - carrierName:vehicle.carrierName, pickupMethod:'WAREHOUSE_PICKUP', loadingType:'PALLET', - vehicleType:'TRUCK', vehicleSize:'15托', driverName:vehicle.driverName, - driverPhone:vehicle.driverPhone, vehiclePlate:vehicle.vehiclePlate, - appointmentAccount:'SELF', sellerName:null, remark:`${marker}-phase5-transfer`, - fees:[{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10, - remark:`${marker}-acceptance`}], - } +/** 本片只接受正式候选的冻结选择器和FBA逐票来源,不补猜来源版本。 */ +export function f40CreateDispatchBody(marker, warehouseId, customerId, candidates) { + if (!Array.isArray(candidates) || !candidates.length) throw new Error('F40缺少本片真实候选') + const ids = new Set(), batchAllocations = [] + const positive = value => /^[1-9]\d*$/.test(id(value)) + const cargo = candidates.map(candidate => { + const selection = candidate?.selection + if (!selection || selection.sourceType !== 'INBOUND_PLAN' || !positive(selection.planPalletId) + || !positive(selection.warehousePalletId) || selection.returnReceiptId != null || selection.relabelNo != null + || candidate.sourceKey !== `PLAN:${id(selection.planPalletId)}` || ids.has(candidate.sourceKey) + || !/^[a-f\d]{64}$/i.test(selection.sourceVersion || '') || candidate.sourceVersion !== selection.sourceVersion + || id(candidate.customerId) !== id(customerId) || id(candidate.warehouseId) !== id(warehouseId) + || quantity(candidate.availablePackages) == null || quantity(candidate.availablePackages) <= 0 + || candidate.destination?.type !== 'FBA' || !candidate.destination.key + || !Array.isArray(candidate.blockReasons) || candidate.blockReasons.length + || !Array.isArray(candidate.appointmentBatches)) throw new Error('F40本片候选身份、库存或冻结版本不完整') + ids.add(candidate.sourceKey) + if (candidate.appointmentBatches.length) { + if (candidate.appointmentBatches.length !== 1) throw new Error('F40跨批次缺少本轮真实逐票分配,不推算') + const batch = candidate.appointmentBatches[0] + if (!/^FBA_APPOINTMENT:[1-9]\d*$/.test(batch.batchKey || '') || id(batch.customerId) !== id(customerId) + || !Array.isArray(batch.sources) || !batch.sources.length + || batch.sources.some(source => !/^(BOOKING:.+|ORDER:[1-9]\d*)$/.test(source.sourceKey || '') + || source.destinationKey !== candidate.destination.key || quantity(source.packageCount) == null || quantity(source.packageCount) <= 0) + || batch.sources.reduce((sum, source) => sum + quantity(source.packageCount), 0) !== quantity(candidate.availablePackages)) + throw new Error('F40预约批次与本片逐票实物不一致') + for (const source of batch.sources) batchAllocations.push({ planPalletId: id(selection.planPalletId), batchKey: batch.batchKey, + customerId: id(customerId), sourceKey: source.sourceKey, destinationKey: source.destinationKey, + packageCount: quantity(source.packageCount), recordVersion: wireVersion(batch.recordVersion) }) + } + return { sourceType: selection.sourceType, planPalletId: id(selection.planPalletId), + warehousePalletId: id(selection.warehousePalletId), returnReceiptId: null, relabelNo: null, sourceVersion: selection.sourceVersion } + }) + return { idempotencyKey: `${marker}-CREATE`.slice(0, 200), warehouseId: id(warehouseId), vehicle: null, driver: null, + plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${marker}-F40-native`, cargo, batchAllocations } +} +/** 调用方可提供只读实测读取器;不按箱数或计费重量构造物理实载值。 */ +export async function resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId) { + const source = context.context?.f40LoadingMeasurements + if (typeof source === 'function') return source({ environment: 'TEST', marker: context.marker, dispatchNo, + warehouseId: id(warehouseId), sourceInboundId: id(producer.sourceInboundId), inboundCode: producer.inboundCode }) + return source?.[dispatchNo] } export function validFragmentCabinetIdentity(workbench, fragment, task) { const container = workbench?.container @@ -129,7 +170,7 @@ export function fulfillmentHistorySnapshot(detail) { return { orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId), parentOrderId: id(detail?.order?.parentOrderId), role: detail?.order?.orderRole, - status: detail?.order?.status, version: Number(detail?.order?.version), + status: detail?.order?.status, version: detail?.order?.version, inboundCode: detail?.order?.inboundCode, orderNo: detail?.order?.orderNo, rawOrder: detail?.order, histories: (detail?.statusHistory || []).map(row => ({ ...row, id: id(row.id) })), @@ -443,61 +484,110 @@ async function fragment(context, source, rootOrderId, sequence) { shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt, palletLabelManifest, rootOrderId } } -async function sendFragment(context, producer, picker, merchantToken, index) { - const customerId = producer.tenantId - const candidates = await api(context, 'GET', '读取该来源入仓码实收排车候选', `${DISPATCH}/candidates?sourceStage=RECEIVED`) - const matches = (Array.isArray(candidates) ? candidates : []).filter(row => row.sourceStage === 'RECEIVED' - && row.inboundCode === producer.inboundCode && id(row.overseasWarehouseId) === id(picker.warehouseId)) - check(context, 'F40 只选择该根履约片的真实实收托盘', matches.length > 0 - && matches.every(row => Number(row.packageCount) > 0), `inboundCode=${producer.inboundCode}, pallets=${matches.length}`) - const dispatch = await api(context, 'POST', '该履约片创建独立真实排车', `${DISPATCH}/pre-arrange`, { - planPalletIds: matches.map(row => id(row.planPalletId)), - }) - const dispatchNo = dispatch.dispatchNo - const vehicle = { - vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机', - driverPhone: '19900000000', carrierName: 'F40测试车队', - estimatedArrivalAt: f40LocalDateTime(new Date(Date.now()+86400000)), - } - await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, vehicle) - const transfer = await api(context, 'POST', '客户正式创建该片派送单', - query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), - f40TransferBody(context.marker,index,vehicle), merchantToken) - const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] - check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`) - const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) - const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) - check(context, 'F40 派送实际物品来源和一箱数量严格匹配', detail.order?.customerId === customerId - && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) - && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) +const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, + verifyDepartureReadiness, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } + +export async function sendFragment(context, producer, picker, merchantToken, index, overrides = {}) { + const { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness, + saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } = { ...F40_DISPATCH_DEPENDENCIES, ...overrides } + const customerId = id(producer.tenantId), warehouseId = id(picker.warehouseId) context.marker = `${context.context.f40BaseMarker}-SEND-${index}` - const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, picker.warehouseId) - check(context, 'F40 正式 PDA 拣货完成', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) - const tripVersion = await confirmFleetLoading(context, MODULE, tripProof) - check(context, 'F40 发车前置就绪', await verifyDepartureReadiness(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) - const departed = await api(context, 'POST', 'F40 正式 PDA 发车', `${PDA}/${dispatchNo}/depart`, - { recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker }, picker.token) - check(context, 'F40 整车发车包含本片排车', departed.status === 'IN_TRANSIT' && departed.tripNo === tripProof.tripNo - && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), `dispatch=${dispatchNo}`) - const outbound = await api(context, 'GET', '回查实际来源出库完成', - query(`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}`, { page:'1',size:'20' })) - check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', outbound.outbound?.status === 'OUTBOUNDED' - && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), - `outbound=${transfer.outboundNo}`) - const beforeDelivery = await api(context, 'GET', '读取本片派送推进前版本', deliveryPath) - const delivering = await api(context,'PUT','实际派送推进配送中', - query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}), - {status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker}) - check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) - const completed = await withDeliveryEventRecovery(context, { - marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id), - sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId), - }, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index)) - check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) - check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) - const tripClosure = await finishFleetTrip(context, MODULE, tripProof) - return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, - receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure } + const rootProof = context.context.f40RootFulfillmentProof + check(context, 'F40 排车具有本轮根来源生产回执', Boolean(rootProof && producer.inboundCode && id(producer.sourceInboundId) + && rootProof.fragments?.some(row => id(row.sourceInboundId) === id(producer.sourceInboundId) + && row.inboundCode === producer.inboundCode && id(row.tenantId) === customerId + && id(row.containerBookingId) === id(producer.containerBookingId))), 'actual-root-source') + const dispatchProof = { marker: context.marker, customerId, warehouseId, sourceInboundId: id(producer.sourceInboundId), + inboundCode: producer.inboundCode, dispatchNo: null, stage: 'PREPARING' } + ;(rootProof.dispatches ||= []).push(dispatchProof) + // 复用同一已验证的写前落盘/未知停写助手;证据对象也保留在根单证明中。 + context.createdResources.phase5OverseasFulfillment = dispatchProof + context = await fulfillmentEvidenceContext(context) + try { + const candidates = await api(context, 'GET', '读取该来源入仓码原生实收排车候选', + query(`${DISPATCH}/candidates`, { warehouseId })) + check(context, 'F40 正式候选列表完整', candidates?.complete === true && Array.isArray(candidates.candidates), 'native-complete-candidate-list') + const matches = candidates.candidates.filter(row => row.selection?.sourceType === 'INBOUND_PLAN' + && row.inboundCode === producer.inboundCode && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId) + check(context, 'F40 只选择本片完整真实实收候选且总量一箱', candidates?.complete === true && matches.length > 0 + && matches.reduce((sum, row) => sum + Number(row.availablePackages), 0) === 1, `inboundCode=${producer.inboundCode}, pallets=${matches.length}`) + dispatchProof.candidates = matches + const createBody = f40CreateDispatchBody(context.marker, warehouseId, customerId, matches) + const dispatch = await api(context, 'POST', '该履约片通过原生创建独立真实整车', DISPATCH, createBody) + const dispatchNo = dispatch?.dispatchNo + check(context, 'F40 创建返回原生整车业务身份及装车前阶段', Boolean(dispatchNo) && dispatch.status === 'ACTIVE' + && dispatch.stage === 'BEFORE_LOADING' && validVersion(dispatch.recordVersion), 'native-create-result') + dispatchProof.dispatchNo = dispatchNo; dispatchProof.createResult = dispatch + const createReplay = await api(context, 'POST', 'F40 原生建单原命令重放', DISPATCH, createBody) + check(context, 'F40 建单重放只恢复原版本及业务结果', createReplay.replayed === true && createReplay.dispatchNo === dispatchNo + && String(createReplay.recordVersion) === String(dispatch.recordVersion) && JSON.stringify(createReplay.result) === JSON.stringify(dispatch.result), 'immutable-create-result') + dispatchProof.action = 'PREPARE_AND_REVIEW' + const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) + dispatchProof.trip = tripProof + dispatchProof.action = 'PDA_PICK' + check(context, 'F40 正式 PDA 拣货核平仍待显式装车', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + dispatchProof.action = 'LOADING_COMPLETE' + const actualMeasurements = await resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId) + await confirmFleetLoading(context, MODULE, tripProof, picker.token, actualMeasurements) + dispatchProof.action = 'ARRANGE_DELIVERY' + const transfer = await arrangeDispatchDelivery(context, dispatchNo) + check(context, 'F40 平台装车后一次安排本片完整客户派送', Boolean(transfer), `dispatch=${dispatchNo}`) + const deliveries = transfer.deliveries, outbounds = transfer.outbounds + check(context, 'F40 同客户同目的仓一片对应唯一出库与派送', deliveries?.length === 1 && outbounds?.length === 1 + && id(deliveries[0].customerId) === customerId && id(outbounds[0].customerId) === customerId + && deliveries[0].outboundNo === outbounds[0].outboundNo && Number(deliveries[0].packageCount) === 1 + && deliveries[0].destination?.key === matches[0].destination.key, 'one-actual-customer-destination') + const outboundNo = outbounds[0].outboundNo + dispatchProof.arrangement = transfer.data?.result; dispatchProof.outboundNo = outboundNo + dispatchProof.deliveryNo = deliveries[0].deliveryNo + const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) + const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) + check(context, 'F40 派送实际物品来源和一箱数量严格匹配', id(detail.order?.customerId) === customerId + && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) + && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) + dispatchProof.action = 'PDA_DEPART' + const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) + check(context, 'F40 双端当前公共版本发车门禁就绪', Boolean(ready), `dispatch=${dispatchNo}`) + const departBody = { recordVersion: wireVersion(ready.data.recordVersion), + idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker } + const departed = await api(context, 'POST', 'F40 正式 PDA 实际发车', `${PDA}/${dispatchNo}/depart`, departBody, picker.token) + check(context, 'F40 本片原生发车进入DELIVERING并且真实出库一箱', departed.dispatchNo === dispatchNo + && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 1 + && departed.result?.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === deliveries[0].deliveryNo, `dispatch=${dispatchNo}`) + const departureReplay = await api(context, 'POST', 'F40 实际发车原命令重放', `${PDA}/${dispatchNo}/depart`, departBody, picker.token) + check(context, 'F40 发车重放保留原公共版本及全部发运结果', departureReplay.replayed === true + && String(departureReplay.recordVersion) === String(departed.recordVersion) + && JSON.stringify(departureReplay.result) === JSON.stringify(departed.result), 'immutable-departure-result') + dispatchProof.departure = departed; dispatchProof.departureReplay = departureReplay; dispatchProof.stage = 'DELIVERING' + const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`) + check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId + && outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED' + && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), `outbound=${outboundNo}`) + const beforeDelivery = await api(context, 'GET', '回查实际发车推进本片派送及其版本', deliveryPath) + check(context, 'F40 实际发车已推进派送DELIVERING且身份未漂移', beforeDelivery.order?.status === 'DELIVERING' + && id(beforeDelivery.order.customerId) === customerId && beforeDelivery.order.outboundNo === outboundNo + && validVersion(beforeDelivery.order.version), 'actual-departure-delivery-facts') + dispatchProof.action = 'SAVE_RECEIPT_AND_EVENT_RECOVERY' + const completed = await withDeliveryEventRecovery(context, { + marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id), + sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId), + }, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index)) + check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) + check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) + const nativeCompletion = await waitState(() => api(context, 'GET', 'F40 回查本片全部正常签收汇总阶段', `${DISPATCH}/${dispatchNo}`), + value => value?.status === 'ACTIVE' && value?.stage === 'DELIVERED') + check(context, 'F40 正常签收自动汇总原生整车DELIVERED', nativeCompletion?.dispatchNo === dispatchNo + && nativeCompletion.status === 'ACTIVE' && nativeCompletion.stage === 'DELIVERED', 'actual-normal-signature-completion') + dispatchProof.normalCompletion = nativeCompletion; dispatchProof.stage = 'DELIVERED' + const tripClosure = await finishFleetTrip(context, MODULE, tripProof) + dispatchProof.tripClosure = tripClosure + await context.checkpoint() + return { producer, dispatchNo, outboundNo, deliveryNo: deliveries[0].deliveryNo, + receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure } + } catch (error) { + dispatchProof.stage = 'FAILED'; dispatchProof.failedAction = dispatchProof.action || 'CREATE' + try { await context.checkpoint() } finally { throw error } + } } /** Only the first actual FBA signature is faulted; caller performs the normal receipt command. */ @@ -763,7 +853,7 @@ async function runRootFulfillment(context) { revisions:await api(scope,'GET','F40 新键前冻结资料版本历史',historyPath)} check(scope,'F40 新键前原签收事实保持完整稳定', JSON.stringify(signed.receiptSnapshot) === JSON.stringify(beforeFresh.delivery),`delivery=${signed.deliveryNo}`) - const saveBody = {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents} + const saveBody = {expectedVersion:wireVersion(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents} const receiptProof = {deliveryNo:signed.deliveryNo,beforeFresh,saveBody,afterFresh:{},afterSameKey:{}} ;(proof.receiptReplayProofs ||= []).push(receiptProof) const saved = await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实', diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index eb901dbe1..18ea27130 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -719,6 +719,33 @@ Assert-Contains $lastlegAgents '海外仓全流程连续覆盖' 'Lastleg 海外 Assert-Contains $overseasPdaAgents '排车拣货与发车' '海外 PDA 排车入口' Assert-Contains $overseasPdaAgents '普通/一件代发出库' '海外 PDA 出库入口' +# 排车新主流程按单号、公开版本和动作资格定位,不再指向独立车次发车请求。 +# 仅为排车断言定向读取 PDA 详细指引,不依赖其他应用的通用索引拆分。 +$dispatchPdaIndex = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/docs/business-index.md') +$dispatchPdaGuide = $overseasPdaAgents + "`n" + $dispatchPdaIndex +Assert-Contains $overseasPdaAgents 'departDispatch' '海外 PDA 当前发车调用入口' +Assert-Contains $dispatchPdaIndex 'actions.PICK/LOADING_COMPLETE/DEPART' '海外 PDA 服务端动作资格' +Assert-Contains $dispatchPdaIndex 'recordVersion' '海外 PDA 排车公开版本' +Assert-Contains $lastlegIndex 'docs/海外仓业务/排车列表统一作业改造契约.md' 'Lastleg 当前排车主契约' +Assert-NotContains $dispatchPdaGuide 'tripDepartureRequest' '海外 PDA 旧车次发车入口退出' +Assert-NotContains $dispatchPdaGuide 'tripRecordVersion' '海外 PDA 旧车次版本退出' +Assert-Contains $lastlegEntry 'dispatch/controller/DispatchAggregateController.java' 'Lastleg 主指引平台整车入口' +Assert-Contains $lastlegEntry 'dispatch/pda/controller/DispatchPdaController.java' 'Lastleg 主指引 PDA 整车入口' +Assert-Contains $lastlegEntry 'DispatchDepartureService.java' 'Lastleg 主指引共用发车服务' +foreach ($lastlegDispatchGuide in @( + @{ Name = 'Lastleg 主指引'; Text = $lastlegEntry }, + @{ Name = 'Lastleg 详细索引'; Text = $lastlegIndex } +)) { + Assert-Contains $lastlegDispatchGuide.Text 'recordVersion' "$($lastlegDispatchGuide.Name) 排车公开版本" + foreach ($obsoleteDispatchRule in @('提交车次版本', 'tripRecordVersion', 'READY_TO_DEPART')) { + Assert-NotContains $lastlegDispatchGuide.Text $obsoleteDispatchRule "$($lastlegDispatchGuide.Name) 旧排车规则退出" + } +} +$lastlegPdaPublicController = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java') +Assert-Contains $lastlegPdaPublicController 'DispatchDepartureService' 'Lastleg PDA 真实入口使用共用发车服务' +Assert-Contains $lastlegPdaPublicController 'DispatchCommands.Depart' 'Lastleg PDA 真实入口使用公开发车命令' +Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java' + # Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 $navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') Assert-Contains $navigationRecovery 'const RECOVERY_WINDOW_MS = 30_000' 'Admin 路由恢复窗口' diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index 347c9ac59..ca65f2279 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -5,8 +5,9 @@ import { createHash, randomUUID } from 'node:crypto' import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' -import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' -import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' +import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam' @@ -20,7 +21,6 @@ export function poolLocalDateTime(date) { requireThat(date instanceof Date && Number.isFinite(date.getTime()), 'VALID_LOCAL_DATE_REQUIRED') return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19) } -const localTime = ms => poolLocalDateTime(new Date(Date.now() + ms)) const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') export const POOL_COVERAGE = Object.freeze([ @@ -271,9 +271,11 @@ async function verifyPauseAndCutoff({ offerNo, bookingNo, salesToken, dispatcher async function poolScenario(context, fixture, { execute = false, readInventory, credentials }) { if (!execute) return poolPlan({ execute: false, runId: 'plan' }) requireThat(fixture?.marker === context.marker && fixture.members?.length === 2 && fixture.pdaToken + && fixture.dispatchNo && fixture.tripProof?.dispatchNo === fixture.dispatchNo + && fixture.members.every(member => member.dispatchNo === fixture.dispatchNo) && typeof readInventory === 'function', 'CURRENT_PHYSICAL_FIXTURE_REQUIRED') const proof = context.createdResources.fleetCapacityPool = { status: 'CHECKING', marker: context.marker, - tripNo: fixture.tripNo, covered: [], remaining: [...POOL_REMAINING], actors: [], orders: [], snapshots: {} } + tripNo: fixture.tripNo, dispatchNo: fixture.dispatchNo, covered: [], remaining: [...POOL_REMAINING], actors: [], orders: [], snapshots: {} } const tripProof = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo) const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}` const key = label => createHash('sha256').update(`${context.marker}:POOL:${label}`).digest('hex') @@ -315,7 +317,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory, for (let n = 0; n < 2; n++) dispatchers.push(await createActor('dispatch', n, [PREFIX + 'manage'])) const fulfillmentToken = await createActor('fulfillment', 0, [PREFIX + 'manage', 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:booking-confirm', 'platform:fleet:trip:edit', - 'platform:overseas-warehouse:inbound-order:smart-dispatch']) + 'platform:overseas-warehouse:dispatch:create']) for (const member of fixture.members) { const credential = credentials?.get(member.customerId) check('Original fixture customer credentials available only in memory', Boolean(credential)) @@ -324,15 +326,23 @@ async function poolScenario(context, fixture, { execute = false, readInventory, await reject('Merchant token cannot read internal capacity pool', 'GET', `${POOL}?page=1&size=20`, undefined, login.accessToken) } proof.covered.push('merchant-cannot-read-pool') + const dispatchPath = `${API}/dispatches/${encode(fixture.dispatchNo)}` + let main = await request('GET', dispatchPath) + check('One native main retains both original unpicked customers', main.status === 'ACTIVE' && main.stage === 'LOADING' + && Number(main.counts?.pickedPackageCount) === 0 && Number(main.counts?.shippedPackageCount) === 0) + await request('POST', `${dispatchPath}/return-to-review`, { recordVersion: main.recordVersion, idempotencyKey: key('RETURN-BEFORE-POOL'), reason: 'TEST零扫码显式退回以追加本轮外接实收' }) + main = await request('GET', dispatchPath) + check('Explicit return preserves main identity and all sources before pool changes', main.dispatchNo === fixture.dispatchNo && main.stage === 'BEFORE_LOADING' && main.reviewState === 'DRAFT') let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`) check('One actual thirty-space trip and two original real pallets', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2) - const planned = controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(p => ({ ...p, palletSpaces: 9 })) + const planned = testPlannedCapacity(plan.cargo, detail.trip.stops).map(p => ({ ...p, palletSpaces: 9 })) await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, pallets: planned, reason: 'TEST容量计划18托位;实际装载另行实测确认' }) detail = await request('GET', tripPath) const offerDraft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion, cutoffAt: new Date(Date.now() + 1800000).toISOString(), contactName: 'TEST调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, dispatchers[0]) let offer = (await request('POST', `${POOL}/${offerDraft.offer.offerNo}/publish`, { tripRecordVersion: offerDraft.offer.tripRecordVersion, offerRecordVersion: offerDraft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, dispatchers[0])).offer + proof.planEvidence = { actual: false, reason: 'TEST销售占位计划18/30,不能作为实载回执', pallets: planned } proof.offerNo = offer.offerNo const destinationKey = plan.cargo[0].destinationKey check('Owned physical sources share exact destination', plan.cargo.every(row => row.destinationKey === destinationKey)) @@ -458,33 +468,49 @@ async function poolScenario(context, fixture, { execute = false, readInventory, check('Sales retains its own history after close', history.records.some(row => row.bookingNo === first.bookingNo) && history.records.every(row => row.salesOwnerUserId === first.salesOwnerUserId)) proof.covered.push('close-and-successful-confirmation-replay') - detail = await request('GET', tripPath) - for (const binding of detail.dispatches) detail = await request('POST', `${tripPath}/dispatches/${encode(binding.dispatchNo)}/review`, { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }) + main = await request('GET', dispatchPath) + check('All appended physical pallets remain on the original one native main before review', main.dispatchNo === fixture.dispatchNo + && main.stage === 'BEFORE_LOADING' && Number(main.counts?.packageCount) === 4 && Number(main.counts?.pickedPackageCount) === 0) + let reviewed = main + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) reviewed = await request('POST', `${dispatchPath}/${action}`, + { recordVersion: reviewed.recordVersion, idempotencyKey: key(`POOL-${action}`), + ...(action === 'submit-review' ? { remark: context.marker } : { decision: 'APPROVED', reason: context.marker }) }) const members = [...fixture.members, ...poolMembers] + check('Original and pool receipts all belong to the same trip main', members.every(member => member.dispatchNo === fixture.dispatchNo)) proof.pickScopes = [] + const namespace = await pickPoolDispatch(context, fixture.pdaToken, fixture.members[0]) + proof.pickScopes.push({ dispatchNo: fixture.dispatchNo, customerIds: [...new Set(members.map(row => row.customerId))], namespace }) + const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken) + check('All four owned and external pallets were really picked within one native main', task.status === 'ACTIVE' && task.stage === 'LOADING' + && task.pickingReconciled === true && Number(task.counts?.packageCount) === 4 && Number(task.counts?.pickedPackageCount) === 4 + && task.items.length === 4 && members.every(member => task.items.some(row => id(row.customerId) === member.customerId + && row.palletNo === member.palletNo && Number(row.pickedPackages) === 1))) + await confirmFleetLoading(context, MODULE, tripProof, fixture.pdaToken, fixture.loadingMeasurements) + const arranged = await arrangeDispatchDelivery(context, fixture.dispatchNo) + check('One native platform arrangement covers all four real sources and both customers', arranged?.deliveries?.length === 2 + && arranged.outbounds?.length === 2 && arranged.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === 4) for (const member of members) { - const namespace = await pickPoolDispatch(context, fixture.pdaToken, member) - proof.pickScopes.push({ customerId: member.customerId, dispatchNo: member.dispatchNo, namespace }) - const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(member.dispatchNo)}`, undefined, fixture.pdaToken) - check('Every owned and external pallet was actually picked', task.items.every(row => Number(row.pickedPackages) === Number(row.scheduledPackages))) + const matches = arranged.deliveries.filter(row => id(row.customerId) === member.customerId) + check('Every source maps to its exact customer delivery without invented child dispatches', matches.length === 1) + Object.assign(member, { outboundNo: matches[0].outboundNo, deliveryNo: matches[0].deliveryNo }) } - const loading = await request('GET', `${tripPath}/loading`) - check('All four original physical pallets present for actual loading', loading.cargo.length === 4 && !loading.blockReason) - const loaded = await request('POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, - pallets: controlledPalletMeasurements(loading.cargo, detail.trip.stops), reason: 'TEST受控实测每实托1托位;不同于之前18托位接单计划' }) - detail = await request('POST', `${tripPath}/loading-review`, { recordVersion: loaded.recordVersion, decision: 'APPROVED' }) - const departBody = { recordVersion: detail.trip.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } - const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.members[0].dispatchNo)}/depart` + const readyTask = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken) + check('Arrangement leaves native main ready in AFTER_LOADING', readyTask.status === 'ACTIVE' && readyTask.stage === 'AFTER_LOADING' && readyTask.actions?.DEPART?.allowed === true) + const departBody = { recordVersion: readyTask.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } + const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}/depart` const departed = await request('POST', departPath, departBody, fixture.pdaToken) - check('One whole-trip actual departure includes exactly four dispatches', departed.status === 'IN_TRANSIT' && Number(departed.shippedPackages) === 4 - && departed.dispatches.length === 4 && members.every(member => departed.dispatches.some(row => row.dispatchNo === member.dispatchNo))) - check('Departure original-key replay is explicit', (await request('POST', departPath, departBody, fixture.pdaToken)).replayed === true) - for (const [index, member] of members.entries()) { - const path = `${API}/delivery-orders/${encode(member.deliveryNo)}?customerId=${encode(member.customerId)}` - const currentDelivery = await request('GET', path) - await request('PUT', path.replace('?', '/status?'), { status: 'DELIVERING', expectedVersion: currentDelivery.order.version, remark: context.marker }) - const signed = await saveReceiptAndComplete(context, { deliveryNo: member.deliveryNo }, member.customerId, index) + check('One native actual departure covers four sources and two customer deliveries', departed.dispatchNo === fixture.dispatchNo + && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 4 + && departed.result.deliveries.length === 2) + const departReplay = await request('POST', departPath, departBody, fixture.pdaToken) + check('Departure original-key replay preserves exact version and documents', departReplay.replayed === true + && id(departReplay.recordVersion) === id(departed.recordVersion) && JSON.stringify(departReplay.result) === JSON.stringify(departed.result)) + for (const [index, delivery] of arranged.deliveries.entries()) { + const actual = await request('GET', `${API}/delivery-orders/${encode(delivery.deliveryNo)}?customerId=${encode(delivery.customerId)}`) + check('Actual departure has already set the customer delivery to DELIVERING', actual.order.status === 'DELIVERING') + const signed = await saveReceiptAndComplete(context, delivery, delivery.customerId, index) check('Receipt actually completes this delivery', signed?.order?.status === 'DELIVERED') + check('Signing cannot create or duplicate agreed fees', JSON.stringify(actual.fees) === JSON.stringify(signed.fees)) } proof.covered.push('shared-real-departure-and-signature') for (const [index, order] of proof.orders.entries()) { @@ -494,7 +520,14 @@ async function poolScenario(context, fixture, { execute = false, readInventory, && commercial.summary.fleetArrangement?.fulfillmentStage === 'COMPLETED' && Number(commercial.summary.fleetArrangement.signedPackages) === 1) const member = poolMembers[index], actual = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${order.customerId}`) const fees = actual.fees.filter(row => row.feeType === 'DELIVERY_FEE' && row.feeDirection === 'RECEIVABLE') - check('One agreed receivable remains, no order/receipt duplicate fee', fees.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY') + const sameCustomerOrders = poolMembers.filter(row => row.customerId === order.customerId) + const externalFees = fees.filter(row => row.currency === 'CNY' && Number(row.amount) === 30) + check('Exactly one agreed external receivable per original source plus the separately uncovered owned service', + externalFees.length === sameCustomerOrders.length && fees.length === sameCustomerOrders.length + 1 + && fees.filter(row => row.currency === 'USD' && Number(row.amount) === 10).length === 1 + && new Set(fees.map(row => id(row.id))).size === fees.length + && sameCustomerOrders.every(source => actual.operations.filter(op => op.operationNode === 'CONFIRM_EXTERNAL_FREIGHT' + && id(op.result).includes(`外接预订 ${source.bookingNo};`)).length === 1)) const merchant = merchantSessions.find(session => session.customerId === order.customerId) const merchantPath = `/api/merchant/v1/last-leg/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}` const own = await request('GET', merchantPath, undefined, merchant.accessToken) @@ -571,9 +604,10 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request const inventory = await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId }) check('Immutable actual incoming STOCK_IN is unique', inventory.scope?.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE' && inventory.ledger.filter(row => row.businessOrderNo === incomingNo && row.businessType === 'STOCK_IN' && Number(row.deltaPackages) === 1).length === 1) - const dispatchBase = `${API}/dispatches`, candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: incomingNo })}`) - check('Received candidate exactly belongs to this commercial incoming', candidates.length === 1 && candidates[0].inboundCode === incomingNo - && id(candidates[0].customerId) === order.customerId && id(candidates[0].warehousePalletId) === id(pallet.warehousePalletId)) + const dispatchBase = `${API}/dispatches`, candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, orderNo: incomingNo })}`) + check('Received native candidate exactly belongs to this commercial incoming', candidates.complete === true && candidates.candidates.length === 1 + && candidates.candidates[0].inboundCode === incomingNo && id(candidates.candidates[0].customerId) === order.customerId + && candidates.candidates[0].palletNo === pallet.palletNo && candidates.candidates[0].selection?.sourceType === 'INBOUND_PLAN') bookingList = await request('GET', `${tripPath}/bookings`); booking = bookingList.records.find(row => row.bookingNo === order.bookingNo) const arrangeBody = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, idempotencyKey: key(`ARRANGE-DISPATCH-${index}`) } @@ -581,13 +615,13 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request const arranged = await request('POST', `${poolPath}/arrange-dispatch`, arrangeBody, fulfillmentToken) const arrangeReplay = await request('POST', `${poolPath}/arrange-dispatch`, arrangeBody, fulfillmentToken) check('One command generates and binds the actual receipt without a manually supplied number', arranged.booking.status === 'BOUND' - && arranged.booking.dispatchNo && arranged.booking.incomingNo === incomingNo && arranged.booking.customerId === order.customerId) + && arranged.booking.dispatchNo === fixture.dispatchNo && arranged.booking.incomingNo === incomingNo && arranged.booking.customerId === order.customerId) check('Arrange replay returns the original dispatch and version', arrangeReplay.replayed === true && arrangeReplay.booking.dispatchNo === arranged.booking.dispatchNo && arrangeReplay.tripRecordVersion === arranged.tripRecordVersion) const dispatch = { dispatchNo: arranged.booking.dispatchNo } if (index === 0) proof.covered.push('pool-fulfillment-explicit-write-permissions', 'pool-arrange-dispatch-without-manual-number') let plan = await request('GET', `${tripPath}/capacity-plan`), detail = await request('GET', tripPath) - const actualMeasurements = controlledPalletMeasurements(plan.cargo, detail.trip.stops) + const plannedMeasurements = testPlannedCapacity(plan.cargo, detail.trip.stops) const restored = await verifyPoolBindingRevalidation({ offerDetail, check, blockedConfirm: async label => { const before = await latest(proof.revalidationDraftNo) @@ -597,7 +631,7 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request check('Revalidation refusal keeps competing draft unchanged', inquiryFact(before) === inquiryFact(await latest(before.bookingNo))) }, replan: () => request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, - pallets: actualMeasurements, reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }), + pallets: plannedMeasurements, reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }), resume: async offer => (await request('POST', `${POOL}/${proof.offerNo}/resume`, { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion, idempotencyKey: key(`RESUME-AFTER-BIND-${index}`), reason: 'TEST实际绑定核量后显式恢复接单' }, dispatcherToken)).offer }) const bound = await latest(order.bookingNo) @@ -605,34 +639,27 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request const allBookings = await request('GET', `${tripPath}/bookings`) const load = rows => rows.reduce((total, row) => { for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) total[dimension] += Number(row[dimension]); return total }, { palletSpaces: 0, weightKg: 0, volumeCbm: 0 }) - const plannedLoad = load(actualMeasurements) + const plannedLoad = load(plannedMeasurements) const reservedLoad = load(allBookings.records.filter(row => ['CONFIRMED', 'RECEIVED'].includes(row.status)).map(row => row.cargo)) const capacity = { palletSpaces: detail.trip.maxPalletSpaces, weightKg: detail.trip.maxLoadKg, volumeCbm: detail.trip.maxVolumeCbm } assertPoolBoundCapacity(restored, destinationKey, plannedLoad, reservedLoad, capacity) check('BOUND reservation is removed exactly once from all capacity dimensions', Number(plannedLoad.palletSpaces) === 3 + index && Number(reservedLoad.palletSpaces) === (index === 0 ? 4 : 0)) - ;(proof.bindingCapacity ||= []).push({ bookingNo: order.bookingNo, status: bound.status, planned: plannedLoad, + ;(proof.bindingCapacity ||= []).push({ bookingNo: order.bookingNo, status: bound.status, actual: false, planned: plannedLoad, reserved: reservedLoad, remaining: restored.destinations.find(row => row.destinationKey === destinationKey).remaining }) - const path = `${dispatchBase}/${encode(dispatch.dispatchNo)}`, freight = await request('GET', `${path}/external-freight`) - check('The one pool commercial source owns the one agreed freight', freight.requiresDeliveryFee === false && freight.quotes.length === 1 - && freight.quotes[0].bookingNo === order.bookingNo && Number(freight.quotes[0].amount) === 30) - detail = await request('GET', tripPath) - const transfer = { idempotencyKey: key(`TRANSFER-${index}`), plannedDeliveryAt: localTime(21600000), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', - loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', vehiclePlate: detail.trip.vehiclePlate, driverName: detail.trip.driverName, - driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', externalFreightHash: freight.snapshotHash, fees: [], remark: context.marker } - const converted = await request('POST', `${path}/transfer-delivery`, transfer) - check('Pool source produces exactly one outbound and delivery', converted.outboundNo && converted.deliveries.length === 1) - const replay = await request('POST', `${path}/transfer-delivery`, transfer) - check('Transfer replay reuses outbound and delivery', replay.replayed === true && replay.outboundNo === converted.outboundNo && replay.deliveries[0].deliveryNo === converted.deliveries[0].deliveryNo) + const nativeItems = await request('GET', `${dispatchBase}/${encode(fixture.dispatchNo)}/items`) + check('Received pool source is present in the one native main without early outbound/delivery', nativeItems.items.some(row => id(row.customerId) === order.customerId + && row.inboundCode === incomingNo && id(row.warehousePalletId) === id(pallet.warehousePalletId) && Number(row.pickedPackages) === 0)) return { customerId: order.customerId, bookingNo: order.bookingNo, businessOrderNo: order.businessOrderNo, incomingNo, dispatchNo: dispatch.dispatchNo, - outboundNo: converted.outboundNo, deliveryNo: converted.deliveries[0].deliveryNo, palletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo } + palletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo } + } export async function runPoolAcceptance(config, { env = process.env, outputRoot = ROOT, runner = runCrossCustomer } = {}) { if (!config.execute) return poolPlan(config) const credentials = new Map(); let restoreWrites try { - const result = await runner(config, { env, outputRoot, + const result = await runner(config, { env, outputRoot, verificationScope: 'CAPACITY_POOL', contextFactory: options => { const context = new AcceptanceRun(options), original = context.request context.request = async function (...args) { diff --git a/scripts/fleet-capacity-pool-test-env.test.mjs b/scripts/fleet-capacity-pool-test-env.test.mjs index 4d6cd974b..f35f9ac86 100644 --- a/scripts/fleet-capacity-pool-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-test-env.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { parsePoolArguments, poolPlan, runPoolAcceptance, assertPoolRemaining, assertSingleCommercial, shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, poolPickScope, pickPoolDispatch, POOL_COVERAGE, POOL_REMAINING, poolInquiryWrite, poolReadIsDenied, assertPoolBoundCapacity, verifyPoolConfirmedCancellation, verifyPoolBindingRevalidation, @@ -122,7 +123,7 @@ test('PDA namespace belongs to the exact run, customer and dispatch while parent assert.throws(() => poolPickScope(context, { dispatchNo: 'DS-A' }), /PICK_SCOPE_REQUIRED/) }) -test('real pickAll helper handles two same-customer dispatches with separate pick/complete keys and exact replays', async () => { +test('real native pickAll preserves separate command namespaces, version zero, exact replays and stale rejection', async () => { const operations = new Map(), requests = [], steps = [], picked = new Map() const dispatches = ['DS-A', 'DS-B'] const context = { @@ -134,26 +135,34 @@ test('real pickAll helper handles two same-customer dispatches with separate pic const dispatchNo = path.split('/dispatches/')[1].split('/')[0], index = dispatches.indexOf(dispatchNo) assert.ok(index >= 0) const response = data => ({ http: 200, body: { code: '200', data }, requestId: 'test', traceId: 'test' }) - if (method === 'GET') return response({ items: [{ dispatchItemId: `${index + 1}`, scheduledPackages: 1, - pickedPackages: picked.get(dispatchNo) || 0, positions: [{ id: `${index + 100}`, availablePackages: 1 }] }] }) + const version = picked.get(dispatchNo) || 0 + if (method === 'GET') return response({ dispatchNo, status: 'ACTIVE', stage: 'LOADING', recordVersion: index === 0 ? version : String(version), + pickingReconciled: version === 1, counts: { packageCount: 1, pickedPackageCount: version }, + items: [{ dispatchItemId: `${index + 1}`, scheduledPackages: 1, pickedPackages: version, + positions: [{ positionId: `${index + 100}`, availablePackages: 1 }] }] }) requests.push({ method, path, body: structuredClone(body) }) + assert.ok(path.endsWith('/pick')) + assert.equal(body.recordVersion, index === 0 ? 0 : '0') const prior = operations.get(body.idempotencyKey), payload = JSON.stringify([path, body]) if (prior) { - if (prior.payload !== payload) return { http: 200, body: { code: 'A0409', message: 'Idempotency key was reused with a different request' } } + assert.equal(prior.payload, payload) return response({ ...prior.result, replayed: true }) } - let result - if (path.endsWith('/pick')) { picked.set(dispatchNo, 1); result = { pickedPackages: 1 } } - else result = { status: 'READY_TO_DEPART', plannedPackages: 1, pickedPackages: 1, remainingPackages: 0, replayed: false } + if (version !== Number(body.recordVersion)) return { http: 409, body: { code: 'A0409' } } + picked.set(dispatchNo, 1) + const result = { recordVersion: '1', result: { pickedPackages: 1 }, counts: { packageCount: 1, pickedPackageCount: 1 }, replayed: false } operations.set(body.idempotencyKey, { payload, result }) return response(result) }, } for (const dispatchNo of dispatches) await pickPoolDispatch(context, 'test-pda', { customerId: '9', dispatchNo }) - assert.equal(operations.size, 4) - assert.equal(requests.length, 8) + assert.equal(operations.size, 2) + assert.equal(requests.length, 6) assert.ok(steps.every(Boolean)) - for (let index = 0; index < requests.length; index += 2) assert.deepEqual(requests[index], requests[index + 1]) + for (let index = 0; index < requests.length; index += 3) { + assert.deepEqual(requests[index], requests[index + 1]) + assert.notEqual(requests[index].body.idempotencyKey, requests[index + 2].body.idempotencyKey) + } }) test('failed helper result stops the pool stage immediately instead of appending a misleading generic failure', async () => { @@ -277,3 +286,31 @@ test('confirmed cancellation verifies both commercial types and reuses exact suc assert.equal(result.remainingPalletSpaces, 12) } }) + + +test('pool orchestration keeps native one-main boundaries, explicit reviews and real loading before whole arrangement', () => { + const source = readFileSync(new URL('./fleet-capacity-pool-test-env.mjs', import.meta.url), 'utf8') + for (const old of ['controlledPalletMeasurements(', '/external-freight', '/loading-review`', "departed.status === 'IN_TRANSIT'", 'sourceStage:', "'/dispatches/pre-arrange'"]) assert.equal(source.includes(old), false, old) + assert.match(source, /testPlannedCapacity\(plan\.cargo, detail\.trip\.stops\)/) + assert.match(source, /actual: false/) + assert.match(source, /arranged\.booking\.dispatchNo === fixture\.dispatchNo/) + assert.match(source, /\['submit-review', 'dispatch-review', 'loading-review'\]/) + const pick = source.indexOf('const namespace = await pickPoolDispatch('), load = source.indexOf('await confirmFleetLoading('), arrange = source.indexOf('await arrangeDispatchDelivery('), depart = source.indexOf("const departed = await request('POST', departPath") + assert.ok(pick > 0 && pick < load && load < arrange && arrange < depart) + assert.match(source, /fixture\.loadingMeasurements/) + assert.match(source, /recordVersion: current\.recordVersion/) + assert.match(source, /readyTask\.actions\?\.DEPART\?\.allowed === true/) + assert.match(source, /JSON\.stringify\(actual\.fees\) === JSON\.stringify\(signed\.fees\)/) + assert.match(source, /sameCustomerOrders\.every\(source => actual\.operations\.filter/) +}) + +test('pool execution explicitly selects capacity verification before handing off the shared runner', async () => { + const config = parsePoolArguments(['--execute', '--environment', 'TEST', '--run-id', 'pool-scope', '--warehouse-id', '10', '--destination-warehouse-id', '11']) + let selected + await assert.rejects(runPoolAcceptance(config, { env: {}, runner: async (actual, options) => { + assert.equal(actual, config) + selected = options.verificationScope + throw Error('STOP_BEFORE_ENVIRONMENT_ACCESS') + } }), /STOP_BEFORE_ENVIRONMENT_ACCESS/) + assert.equal(selected, 'CAPACITY_POOL') +}) diff --git a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs index 9c8720cfa..470a20cef 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs @@ -58,10 +58,18 @@ test('eight-hour cutoff has coherent later departure delivery and driver end wit assert.deepEqual(grayUiSchedule(now + 999), schedule) assert.match(schedule.cutoffAt, /Z$/); assert.match(schedule.plannedDepartureAt, /Z$/) assert.doesNotMatch(schedule.plannedDeliveryAt, /Z$/) - const body = { idempotencyKey: 'stable', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] } + const body = { idempotencyKey: 'stable', recordVersion: '9007199254740993', groups: [ + { groupKey: 'A:FBA:ONT8', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, + { groupKey: 'B:FBA:ONT8', plannedDeliveryAt: 'other', fees: [{ amount: 20 }] }, + ] } const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) - assert.equal(applied.plannedDeliveryAt, schedule.plannedDeliveryAt) - assert.equal(applied.idempotencyKey, body.idempotencyKey); assert.deepEqual(applied.fees, body.fees); assert.equal(body.plannedDeliveryAt, 'old') + assert.deepEqual(applied.groups.map(group => group.plannedDeliveryAt), [schedule.plannedDeliveryAt, schedule.plannedDeliveryAt].map(value => new Date(value).toISOString())) + assert.equal(applied.idempotencyKey, body.idempotencyKey); assert.equal(applied.recordVersion, body.recordVersion) + assert.deepEqual(applied.groups.map(group => group.fees), body.groups.map(group => group.fees)) + assert.deepEqual(applied.groups.map(group => group.groupKey), body.groups.map(group => group.groupKey)) + assert.deepEqual(body.groups.map(group => group.plannedDeliveryAt), ['old', 'other']) + assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', + { idempotencyKey: 'stable', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, schedule), /NATIVE_DELIVERY/) assert.equal(applyGrayUiTransportWindow('POST', '/api/auth/login', body, schedule), body) assert.equal(applyGrayUiTransportWindow('GET', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule), body) assert.throws(() => grayUiSchedule(NaN), /CLOCK/) diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs index b05c9973c..783df1e02 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs @@ -7,7 +7,7 @@ import { createHash, randomUUID } from 'node:crypto' import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs' import { parseCrossCustomerArguments } from './fleet-cross-customer-test-env.mjs' import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs' -import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' import { createGrayEvidenceReaders } from './gray-regression-evidence.mjs' import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' @@ -77,8 +77,10 @@ export function grayUiSchedule(now = Date.now()) { export function applyGrayUiTransportWindow(method, path, body, schedule) { if (method !== 'POST' || !/^\/api\/platform\/v1\/last-leg\/dispatches\/[^/]+\/transfer-delivery$/.test(path)) return body - demand(body && schedule?.plannedDeliveryAt, 'GRAY_UI_DELIVERY_WINDOW_REQUIRED') - return { ...body, plannedDeliveryAt: schedule.plannedDeliveryAt } + demand(body && Array.isArray(body.groups) && body.groups.length && body.groups.every(group => group?.groupKey) + && schedule?.plannedDeliveryAt && Number.isFinite(Date.parse(schedule.plannedDeliveryAt)), 'GRAY_UI_NATIVE_DELIVERY_WINDOW_REQUIRED') + const plannedDeliveryAt = new Date(schedule.plannedDeliveryAt).toISOString() + return { ...body, groups: body.groups.map(group => ({ ...group, plannedDeliveryAt })) } } /** Private directory override is for isolated offline tests, never exposed as a CLI option. */ @@ -228,25 +230,35 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) save('fixture.json', fixture) const trip = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo), tripPath = `${API}/fleet/trips/${encodeURIComponent(fixture.tripNo)}` - check('Existing producer created exactly two untouched customer dispatches', fixture.members.length === 2 && trip) - let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`) - check('Owned fixture vehicle has actual 30-space capacity', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2) - // GRAY delivery ETA was set on the producer's real transfer command, so all four times stay ordered. - detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion, + check('Existing producer created two untouched customer sources on exactly one native main', fixture.members.length === 2 && trip + && fixture.dispatchNo && fixture.members.every(member => member.dispatchNo === fixture.dispatchNo) && fixture.tripProof?.dispatchNo === fixture.dispatchNo) + const dispatchPath = `${API}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + let main = await request('GET', dispatchPath), detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`) + check('Owned fixture vehicle has actual 30-space capacity with two real source pallets', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2 + && main.status === 'ACTIVE' && main.stage === 'LOADING' && Number(main.counts?.pickedPackageCount) === 0 && Number(main.counts?.shippedPackageCount) === 0) + await request('POST', `${dispatchPath}/return-to-review`, { recordVersion: main.recordVersion, + idempotencyKey: createHash('sha256').update(`${context.marker}:UI:RETURN`).digest('hex'), reason: 'TEST零扫码明确退回以设置UI计划时窗' }) + main = await request('GET', dispatchPath) + check('Native UI plan edits only the explicit unpicked BEFORE_LOADING main', main.stage === 'BEFORE_LOADING' && main.reviewState === 'DRAFT') + const uiPlanBody = { ...fixture.tripProof.updateRequest, recordVersion: main.recordVersion, + idempotencyKey: createHash('sha256').update(`${context.marker}:UI:PLAN`).digest('hex'), plannedDepartureAt: schedule?.plannedDepartureAt || new Date(Date.now() + 4 * 3600000).toISOString(), - expectedDriverEndAt: schedule?.expectedDriverEndAt || detail.trip.expectedDriverEndAt, - stops: detail.trip.stops, reason: gray ? '灰度UI验收:统一截止、发车、到站与司机结束时间,不执行发车' : 'UI验收窗口:四小时后计划发车,不执行发车' }) - if (gray) { - check('GRAY trip departure and driver end follow the explicit UI window', Date.parse(detail.trip.plannedDepartureAt) === Date.parse(schedule.plannedDepartureAt) - && Date.parse(detail.trip.expectedDriverEndAt) === Date.parse(schedule.expectedDriverEndAt)) - for (const member of fixture.members) { - const delivery = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${member.customerId}`) - check('Original customer delivery ETA follows the same valid UI window', delivery.order.plannedDeliveryAt === schedule.plannedDeliveryAt) - } - } + estimatedArrivalAt: schedule ? new Date(schedule.plannedDeliveryAt).toISOString() : null, + driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, + evidence: { expectedTaskEndAt: schedule?.expectedDriverEndAt || fixture.tripProof.expectedDriverEndAt } }, + remark: 'TEST仅UI验收计划,不执行实载发车' } + let reviewed = await request('PUT', dispatchPath, uiPlanBody) + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) reviewed = await request('POST', `${dispatchPath}/${action}`, + { recordVersion: reviewed.recordVersion, idempotencyKey: createHash('sha256').update(`${context.marker}:UI:${action}`).digest('hex'), + ...(action === 'submit-review' ? { remark: context.marker } : { decision: 'APPROVED', reason: context.marker }) }) + detail = await request('GET', tripPath) + if (gray) check('GRAY native trip departure and driver end follow the explicit UI window', Date.parse(detail.trip.plannedDepartureAt) === Date.parse(schedule.plannedDepartureAt) + && Date.parse(detail.trip.expectedDriverEndAt) === Date.parse(schedule.expectedDriverEndAt)) plan = await request('GET', `${tripPath}/capacity-plan`) + const plannedPallets = testPlannedCapacity(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })) await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, - pallets: controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })), reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' }) + pallets: plannedPallets, reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' }) + proof.planEvidence = { actual: false, pallets: plannedPallets, reason: 'TEST UI销售计划18/30,不是实载测量' } const permissions = await request('GET', `${IAM}/permissions`), actorTokens = [] const key = suffix => createHash('sha256').update(`${context.marker}:UI:${suffix}`).digest('hex') for (const kind of ['SALES_A', 'SALES_B', 'MANAGER']) { @@ -275,7 +287,7 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, offerRecordVersion: draft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, actorTokens[2])).offer check('OPEN UI offer is available with exactly 12 planned spaces', offer.status === 'OPEN' && offer.canCreateInquiry && offer.canConfirm && offer.destinations.length === 1 && Number(offer.destinations[0].remaining.palletSpaces) === 12) - proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo + proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo; proof.dispatchNo = fixture.dispatchNo for (let index = 0; index < draftCount; index++) { const owner = index < perSales ? 0 : 1, member = fixture.members[owner] const body = uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`), gray) @@ -314,12 +326,14 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, check('UI fixture has not picked departed or signed cargo', detail.trip.status === 'PLANNED') for (const member of fixture.members) { const dispatch = await request('GET', `${API}/dispatches/${member.dispatchNo}`) - check('Original real cargo remains unpicked', Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + check('Original real cargo remains unpicked', dispatch.dispatchNo === fixture.dispatchNo && dispatch.status === 'ACTIVE' && dispatch.stage === 'LOADING' + && Number(dispatch.counts?.pickedPackageCount) === 0 && Number(dispatch.counts?.shippedPackageCount) === 0 + && dispatch.documents?.outbounds?.length === 0 && dispatch.documents?.deliveries?.length === 0) } const final = await request('GET', `${POOL}/${offer.offerNo}`, undefined, actorTokens[2]) check(`UI handoff keeps publication OPEN and ${draftCount} drafts untouched`, final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === draftCount) proof.status = `OPEN_AND_${draftCount}_DRAFTS_READY_FOR_UI` - secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, + secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, dispatchNo: fixture.dispatchNo, warehouseId: fixture.warehouseId, destinationWarehouseId: config.destinationWarehouseId, cutoffAt: final.offer.cutoffAt, plannedDepartureAt: final.offer.plannedDepartureAt, ...(gray ? { uiSchedule: schedule } : {}), customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) } diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs index d70fa5185..c57a3c7b9 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, unlinkSync, rmdirSync } from 'node:fs' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { PRIVATE_DIRECTORY, privateCredentialsPath, parseUiFixtureArguments, uiFixturePlan, - createPrivateCredentialStore, publicUiEvidence, uiInquiryBody, runUiFixture } from './fleet-capacity-pool-ui-fixture-test-env.mjs' + createPrivateCredentialStore, publicUiEvidence, uiInquiryBody, runUiFixture, applyGrayUiTransportWindow, grayUiSchedule } from './fleet-capacity-pool-ui-fixture-test-env.mjs' const credentials = join(PRIVATE_DIRECTORY, 'offline-contract-example.json') const execute = ['--execute', '--environment', 'TEST', '--run-id', 'ui-contract', '--warehouse-id', '10', @@ -112,3 +112,34 @@ test('plan lists minimal independent identities and explicitly excludes full acc assert.match(plan.boundary, /completed-order fabrication/) assert.match(plan.boundary, /Retain business samples and UI accounts/) }) + + +test('UI fixture prepares one shared native main and an explicitly planned 18-of-30 capacity without fake deliveries or loading', () => { + const source = readFileSync(new URL('./fleet-capacity-pool-ui-fixture-test-env.mjs', import.meta.url), 'utf8') + assert.match(source, /fixture\.members\.every\(member => member\.dispatchNo === fixture\.dispatchNo\)/) + assert.match(source, /return-to-review/) + assert.match(source, /request\('PUT', dispatchPath, uiPlanBody\)/) + assert.match(source, /\['submit-review', 'dispatch-review', 'loading-review'\]/) + assert.match(source, /testPlannedCapacity\(plan\.cargo, detail\.trip\.stops\)\.map\(row => \(\{ \.\.\.row, palletSpaces: 9 \}\)\)/) + assert.match(source, /proof\.planEvidence = \{ actual: false/) + assert.match(source, /dispatch\.documents\?\.outbounds\?\.length === 0/) + assert.match(source, /dispatch\.documents\?\.deliveries\?\.length === 0/) + for (const old of ['controlledPalletMeasurements(', "request('PUT', `${tripPath}/plan`", "member.deliveryNo", "dispatch.pickedPackageCount", "dispatch.shippedPackageCount"]) assert.equal(source.includes(old), false, old) + assert.equal(source.includes('/loading-complete'), false) + assert.equal(source.includes('/depart`'), false) +}) + + +test('native GRAY delivery window preserves per-customer group identity and never adds a retired top-level ETA', () => { + const schedule = grayUiSchedule(Date.parse('2030-01-01T00:00:00Z')) + const body = { recordVersion: '0', idempotencyKey: 'same-command', cargoHash: 'real-cargo', loadingCompletionHash: 'real-load', + groups: [{ groupKey: 'A:FBA:1', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, { groupKey: 'B:FBA:1', plannedDeliveryAt: 'old', fees: [] }] } + const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) + assert.equal(applied.recordVersion, '0'); assert.equal(applied.idempotencyKey, body.idempotencyKey) + assert.equal(Object.hasOwn(applied, 'plannedDeliveryAt'), false) + assert.deepEqual(applied.groups.map(group => group.groupKey), body.groups.map(group => group.groupKey)) + assert.ok(applied.groups.every(group => group.plannedDeliveryAt === new Date(schedule.plannedDeliveryAt).toISOString())) + assert.deepEqual(applied.groups[0].fees, body.groups[0].fees) + assert.equal(body.groups[0].plannedDeliveryAt, 'old') + assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', { plannedDeliveryAt: 'old' }, schedule), /NATIVE_DELIVERY_WINDOW_REQUIRED/) +}) diff --git a/scripts/fleet-cross-customer-test-env.mjs b/scripts/fleet-cross-customer-test-env.mjs index f087ac219..a09d3edc7 100644 --- a/scripts/fleet-cross-customer-test-env.mjs +++ b/scripts/fleet-cross-customer-test-env.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs' +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync, fsyncSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { dirname, resolve, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -34,7 +34,7 @@ export function parseCrossCustomerArguments(args) { } export function crossCustomerPlan(config) { return { environment: 'TEST', execute: config.execute, runId: config.runId, warehouseId: config.warehouseId || null, destinationWarehouseId: config.destinationWarehouseId || null, - output: `reports/fleet-cross-${config.runId}`, stages: ['two new customers', 'two ordinary transfer receipts', 'two dispatches on one trip', + output: `reports/fleet-cross-${config.runId}`, stages: ['two new customers', 'two ordinary transfer receipts', 'one native cross-customer dispatch and its unique automatic trip', 'unready customer gate rejection', 'shared departure and replay', 'independent receipts', 'trip completion and separate resource release'], limits: 'Prerequisite rejection is not mid-transaction rollback. No deployment, configuration change, SQL writes or automatic rerun. Retain business samples; disable temporary PDA in finally.' } } @@ -45,17 +45,19 @@ export async function runCrossCustomer(config, dependencies = {}) { /** Not a CLI mode: called only by the explicit GRAY pool entry with its verified pool scenario. */ export async function runFleetPoolGrayCrossCustomer(config, dependencies = {}) { - if (Object.keys(dependencies).some(key => !['env', 'outputRoot', 'verify', 'contextFactory'].includes(key))) + if (Object.keys(dependencies).some(key => !['env', 'outputRoot', 'verify', 'contextFactory', 'verificationScope'].includes(key))) throw Error('Gray pool cannot override evidence readers or fixture lifecycle') + if (dependencies.verificationScope != null && dependencies.verificationScope !== 'CAPACITY_POOL') throw Error('Gray pool requires explicit CAPACITY_POOL verification scope') if (config.execute && (typeof dependencies.verify !== 'function' || typeof dependencies.contextFactory !== 'function')) throw Error('Gray pool requires its dedicated scenario and journaled context') - return runCrossCustomerCore(config, dependencies, true) + return runCrossCustomerCore(config, { ...dependencies, verificationScope: 'CAPACITY_POOL' }, true) } async function runCrossCustomerCore(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer, - finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}, gray = false) { + verificationScope = 'CROSS_CUSTOMER', finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}, gray = false) { + if (!['CROSS_CUSTOMER', 'CAPACITY_POOL', 'SAME_ROUTE_COST'].includes(verificationScope) || gray && verificationScope !== 'CAPACITY_POOL') throw Error('Explicit supported verification scope required') const environment = gray ? 'GRAY' : 'TEST', reportName = `${gray ? 'fleet-pool-gray' : 'fleet-cross'}-${config.runId}` - const plan = { ...crossCustomerPlan(config), environment, output: `reports/${reportName}`, + const plan = { ...crossCustomerPlan(config), verificationScope, environment, output: `reports/${reportName}`, ...(gray ? { scope: 'FLEET_CAPACITY_POOL_GRAY', stages: ['current pool fixture and full pool HTTP matrix', 'trip completion and separate resource release'] } : {}) } if (!config.execute) return plan if (config.environment !== environment || !(gray ? ['gray', 'prod'].includes(env.CHONGCHONG_ENV) : env.CHONGCHONG_ENV === 'test') || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) @@ -71,10 +73,14 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO const reports = resolve(outputRoot, 'reports'), output = join(reports, reportName) mkdirSync(reports, { recursive: true }) const lockPath = join(reports, '.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') - let context, picker, createdPicker, initialized = false, report, evidenceIndex = 0, requestIndex = 0 + let context, picker, createdPicker, initialized = false, report, evidenceIndex = 0, requestIndex = 0, stopped = null const sessions = new Map() const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN/.test(name)).map(([, value]) => value) - const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, secrets), null, 2) + '\n') + const save = (name, value) => { + const descriptor = openSync(join(output, name), 'w') + try { writeFileSync(descriptor, JSON.stringify(redactEvidence(value, secrets), null, 2) + '\n'); fsyncSync(descriptor) } + finally { closeSync(descriptor) } + } try { writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId })) mkdirSync(output); initialized = true; mkdirSync(join(output, 'evidence')); mkdirSync(join(output, 'requests')) @@ -88,13 +94,50 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO const options = gray ? createFleetPoolGrayOptions(transport) : validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), ...transport, environment: 'test' }) context = contextFactory(options) + const previousCheckpoint = context.checkpoint + context.checkpoint = async () => { + try { + if (previousCheckpoint) await previousCheckpoint.call(context) + save('progress.json', { marker: context.marker, steps: context.steps, createdResources: context.createdResources }) + } catch (error) { + stopped ||= new Error('Checkpoint persistence failed; no further business request may be sent') + context.createdResources.fleetWriteBoundary = { status: 'STOPPED', outcome: 'PERSISTENCE_FAILED', reason: 'CHECKPOINT_FAILED' } + throw stopped + } + } for (const method of ['request', 'requestWithBody']) { const original = context[method] context[method] = async function (...args) { - const number = ++requestIndex, [verb, path, body] = args - const record = { number, method: verb, path, stage: 'INTENT', body: body instanceof FormData ? '[multipart file]' : body } - save(`requests/${number}.json`, record) - const response = await original.apply(this, args) + const [verb, path, body] = args + const identityCleanup = verb === 'POST' && path === '/api/auth/logout' || verb === 'PUT' + && [picker?.membershipId, createdPicker?.membershipId].filter(Boolean).some(value => path === `/api/platform/v1/iam/users/${value}/status`) + && body?.status === 'DISABLED' + if (stopped && !identityCleanup) throw stopped + const number = ++requestIndex + const write = !['GET', 'HEAD', 'OPTIONS'].includes(String(verb).toUpperCase()) + let evidenceBody = body + if (body instanceof FormData) { + evidenceBody = { multipart: [] } + for (const [field, value] of body.entries()) evidenceBody.multipart.push(typeof value === 'string' ? { field, value } + : { field, name: value.name, type: value.type, size: value.size, sha256: createHash('sha256').update(Buffer.from(await value.arrayBuffer())).digest('hex') }) + } + const record = { number, method: verb, path, stage: 'INTENT', headers: args[4], body: evidenceBody } + let response, sent = false + try { + save(`requests/${number}.json`, record) + await context.checkpoint() + sent = true + response = await original.apply(this, args) + } catch (error) { + if (write) { + stopped ||= new Error(`${sent ? 'Unknown write outcome' : 'Write checkpoint failed before send'}: ${verb} ${path}; preserve original request, do not resend`) + record.stage = sent ? 'UNKNOWN_RESULT' : 'NOT_SENT' + context.createdResources.fleetWriteBoundary = { status: 'STOPPED', method: verb, path, outcome: record.stage } + save(`requests/${number}.json`, { ...record, error: String(error?.message || error) }) + await context.checkpoint().catch(() => {}) + } + throw error + } if (verb === 'POST' && path === '/api/platform/v1/iam/users' && ok(response) && String(body?.overseasWarehouseId) === config.warehouseId && body?.username?.startsWith('pda_overseas_')) { createdPicker = { membershipId: response.body?.data?.membershipId } @@ -104,8 +147,19 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO sessions.set(tokens.accessToken, tokens.refreshToken) secrets.push(tokens.accessToken, tokens.refreshToken) } + const code = String(response?.body?.code || ''), native = /^\/api\/(?:platform|pda)\/v1\/last-leg\/dispatches(?:\/|$)/.test(path) + const definite = Number.isInteger(response?.http) && response.http >= 200 && response.http < 500 && response.requestId && response.traceId + && (response.http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (response.http === 200 || response.http >= 400)) + && !(native && code === '200' && response.body.data == null) + if (write && !definite) { + stopped ||= new Error(`Unknown write outcome: ${verb} ${path}; preserve original request, do not resend`) + context.createdResources.fleetWriteBoundary = { status: 'STOPPED', method: verb, path, outcome: 'UNKNOWN_RESULT', requestId: response?.requestId, traceId: response?.traceId } + save(`requests/${number}.json`, { ...record, stage: 'UNKNOWN_RESULT', response }) + await context.checkpoint() + throw stopped + } save(`requests/${number}.json`, { ...record, stage: 'RESPONSE', response }) - save('progress.json', { marker: context.marker, steps: context.steps, createdResources: context.createdResources }) + await context.checkpoint() return response } } @@ -125,10 +179,23 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO } const fixture = await prepare(context, { execute: true, warehouseId: config.warehouseId, destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) save('fixture.json', fixture) + if (fixture?.marker !== context.marker || !fixture.dispatchNo || !fixture.tripNo || String(fixture.warehouseId) !== config.warehouseId + || fixture.members?.length !== 2 || new Set(fixture.members.map(member => member.customerId)).size !== 2 + || fixture.members.some(member => member.marker !== context.marker || member.dispatchNo !== fixture.dispatchNo || !member.customerId || member.receiptVerified !== true)) + throw Error('Current fixture requires one exact native main and two owned customer receipts') + const trips = context.createdResources.fleetTrips?.filter(row => row.tripNo === fixture.tripNo && row.dispatchNo === fixture.dispatchNo) + if (trips?.length !== 1 || fixture.tripProof !== trips[0] || String(trips[0].warehouseId) !== config.warehouseId) + throw Error('Original fixture native main/trip proof missing or ambiguous for resource completion') + const trip = trips[0] + await context.checkpoint() const verification = await verify(context, fixture, { execute: true, readInventory }) - if (verification?.status !== 'VERIFIED' || verification.tripNo !== fixture.tripNo) throw Error('Current fixture verification incomplete') - const trip = context.createdResources.fleetTrips?.find(row => row.tripNo === fixture.tripNo) - if (!trip) throw Error('Original fixture trip missing for resource completion') + const proofKey = { CROSS_CUSTOMER: 'fleetCrossCustomer', CAPACITY_POOL: 'fleetCapacityPool', SAME_ROUTE_COST: 'fleetSameRouteCost' }[verificationScope] + const authoritative = context.createdResources[proofKey] + if (verification !== authoritative || verification?.status !== 'VERIFIED' || verification.marker !== context.marker + || verification.dispatchNo !== fixture.dispatchNo || verification.tripNo !== fixture.tripNo) + throw Error('Current fixture verification incomplete or not from the explicitly selected scope') + if (fixture.tripProof !== trip || trip.dispatchNo !== fixture.dispatchNo || trip.tripNo !== fixture.tripNo) throw Error('Native main resource proof changed during verification') + await context.checkpoint() const beforeClosure = [] for (const member of fixture.members) beforeClosure.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: config.warehouseId })) await finish(context, 'fleet-cross-customer-closure', trip) @@ -146,8 +213,10 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO if (context) { let sessionsClosed = true for (const [accessToken, refreshToken] of sessions) { - const response = await context.request('POST', '/api/auth/logout', { refreshToken }, accessToken) - if (!ok(response) && response.http !== 401 && String(response.body?.code) !== 'A0401') sessionsClosed = false + try { + const response = await context.request('POST', '/api/auth/logout', { refreshToken }, accessToken) + if (!ok(response) && response.http !== 401 && String(response.body?.code) !== 'A0401') sessionsClosed = false + } catch { sessionsClosed = false } } report.sessionsClosed = sessionsClosed report.summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) @@ -155,7 +224,7 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO report.marker = context.marker; report.createdResources = context.createdResources; report.steps = context.steps } report ||= { success: false } - report.environment = environment; report.runtimeProfile = env.CHONGCHONG_ENV + report.verificationScope = verificationScope; report.environment = environment; report.runtimeProfile = env.CHONGCHONG_ENV if (gray) report.scope = 'FLEET_CAPACITY_POOL_GRAY' report.requestCount = requestIndex; report.inventorySnapshots = evidenceIndex; report.goalComplete = false try { if (initialized) save('report.json', report) } finally { closeSync(lock); unlinkSync(lockPath) } diff --git a/scripts/fleet-cross-customer-test-env.test.mjs b/scripts/fleet-cross-customer-test-env.test.mjs index 660bc30ba..d2ed2d623 100644 --- a/scripts/fleet-cross-customer-test-env.test.mjs +++ b/scripts/fleet-cross-customer-test-env.test.mjs @@ -14,7 +14,7 @@ function harness(root, fail = false) { calls.push({ method, path }) const data = path === '/api/auth/login' ? { accessToken: 'private-access', refreshToken: 'private-refresh' } : path.includes('warehouse-options') ? [{ id: '900' }] : {} - return { http: 200, body: { code: '200', data } } + return { http: 200, body: { code: '200', data }, requestId: 'offline-request', traceId: 'offline-trace' } }, async requestWithBody() { throw Error('Unexpected multipart') } } return { calls, cleanup, context, options: { env, outputRoot: root, contextFactory: () => context, readers: { preflight: async () => {}, readFleetInventory: async scope => ({ scope, balances: [], positions: [], ledger: [] }) }, @@ -24,11 +24,13 @@ function harness(root, fail = false) { assert.equal(options.pdaToken, 'private-pda') await context.request('POST', '/api/test-owned-command', { idempotencyKey: 'stable-command', password: 'private-pda-password' }) if (fail) throw Error('FIXTURE_FAILED') - return { pdaToken: options.pdaToken, marker: context.marker, tripNo: 'TR-OFFLINE', members: [{ customerId: '42' }, { customerId: '43' }] } + const tripProof = { marker: 'FT-OWNED', dispatchNo: 'DS-ONE', tripNo: 'TR-OFFLINE', warehouseId: '900' } + context.createdResources.fleetTrips = [tripProof] + return { pdaToken: options.pdaToken, marker: context.marker, dispatchNo: 'DS-ONE', tripNo: 'TR-OFFLINE', warehouseId: '900', tripProof, + members: ['42', '43'].map(customerId => ({ customerId, marker: context.marker, dispatchNo: 'DS-ONE', receiptVerified: true })) } }, verify: async (_context, _fixture, options) => { await options.readInventory({ marker: context.marker, customerId: '42', warehouseId: '900' }) - context.createdResources.fleetCrossCustomer = { status: 'VERIFIED', tripNo: 'TR-OFFLINE' } - context.createdResources.fleetTrips = [{ tripNo: 'TR-OFFLINE' }] + context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: 'DS-ONE', status: 'VERIFIED', tripNo: 'TR-OFFLINE' } return context.createdResources.fleetCrossCustomer }, finish: async (_context, _module, trip) => { trip.closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, @@ -82,7 +84,7 @@ test('operator creation followed by login failure still disables the newly creat try { const h = harness(root), original = h.context.request h.context.request = async function (...args) { - if (args[1] === '/api/platform/v1/iam/users') return { http: 200, body: { code: '200', data: { membershipId: '82' } } } + if (args[1] === '/api/platform/v1/iam/users') return { http: 200, body: { code: '200', data: { membershipId: '82' } }, requestId: 'offline-request', traceId: 'offline-trace' } return original.apply(this, args) } h.options.pickerCreate = async context => { @@ -99,3 +101,88 @@ test('operator creation followed by login failure still disables the newly creat rmSync(root, { recursive: true, force: true }) } }) + + +for (const [verificationScope, proofKey] of [['CROSS_CUSTOMER', 'fleetCrossCustomer'], ['CAPACITY_POOL', 'fleetCapacityPool'], ['SAME_ROUTE_COST', 'fleetSameRouteCost']]) { + test(`explicit ${verificationScope} requires its own authoritative native proof`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root); h.options.verificationScope = verificationScope + h.options.verify = async (context, fixture) => { + const proof = { marker: context.marker, dispatchNo: fixture.dispatchNo, tripNo: fixture.tripNo, status: 'VERIFIED' } + context.createdResources[proofKey] = proof; return proof + } + const report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, true); assert.equal(report.verificationScope, verificationScope) + } finally { assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-cross-contract-')); rmSync(root, { recursive: true, force: true }) } + }) +} + +for (const mismatch of ['wrong scope', 'wrong dispatch', 'wrong marker', 'unregistered proof', 'foreign trip proof']) { + test(`native proof mismatch cannot close resources: ${mismatch}`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root); let closed = false + if (mismatch === 'foreign trip proof') { + const prepare = h.options.prepare + h.options.prepare = async (...args) => { const fixture = await prepare(...args); fixture.tripProof = { ...fixture.tripProof }; return fixture } + } + h.options.verify = async (context, fixture) => { + const proof = { marker: mismatch === 'wrong marker' ? 'OTHER' : context.marker, + dispatchNo: mismatch === 'wrong dispatch' ? 'DS-OTHER' : fixture.dispatchNo, tripNo: fixture.tripNo, status: 'VERIFIED' } + if (mismatch !== 'unregistered proof') context.createdResources[mismatch === 'wrong scope' ? 'fleetCapacityPool' : 'fleetCrossCustomer'] = proof + return proof + } + h.options.finish = async () => { closed = true } + const report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, false); assert.equal(closed, false); assert.deepEqual(h.cleanup, ['81']) + } finally { assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-cross-contract-')); rmSync(root, { recursive: true, force: true }) } + }) +} + +test('native helper checkpoint snapshots and original write intent are durable before HTTP', async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root), original = h.context.request + h.context.request = async function (...args) { + if (args[1] === '/api/test-owned-command') { + const folder = join(root, 'reports/fleet-cross-offline-one') + const progress = JSON.parse(readFileSync(join(folder, 'progress.json'), 'utf8')) + assert.equal(progress.createdResources.nativeBeforeSend.dispatchNo, 'DS-ONE') + const requests = readdirSync(join(folder, 'requests')).map(name => JSON.parse(readFileSync(join(folder, 'requests', name), 'utf8'))) + const intent = requests.find(row => row.path === args[1]) + assert.equal(intent.stage, 'INTENT'); assert.equal(intent.body.idempotencyKey, 'stable-command') + } + return original.apply(this, args) + } + const prepare = h.options.prepare + h.options.prepare = async (context, options) => { + context.createdResources.nativeBeforeSend = { dispatchNo: 'DS-ONE' }; await context.checkpoint() + return prepare(context, options) + } + assert.equal((await runCrossCustomer(config(), h.options)).success, true) + } finally { assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-cross-contract-')); rmSync(root, { recursive: true, force: true }) } +}) + +test('unknown write retains original intent, blocks later business requests and still attempts owned identity cleanup', async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root), original = h.context.request + h.context.request = async function (...args) { + if (args[1] === '/api/unknown-command') { h.calls.push({ method: args[0], path: args[1] }); return { http: 503, body: { code: 'S0503' }, requestId: 'offline-request', traceId: 'offline-trace' } } + return original.apply(this, args) + } + h.options.prepare = async context => { + await assert.rejects(context.request('POST', '/api/unknown-command', { idempotencyKey: 'original-unknown', recordVersion: '0' }), /Unknown write outcome/) + await assert.rejects(context.request('POST', '/api/forbidden-followup', { idempotencyKey: 'must-not-send' }), /Unknown write outcome/) + throw Error('UNKNOWN_STOPPED') + } + const report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, false); assert.deepEqual(h.cleanup, ['81']); assert.equal(report.sessionsClosed, true) + assert.equal(h.calls.some(row => row.path === '/api/forbidden-followup'), false) + const folder = join(root, 'reports/fleet-cross-offline-one/requests') + const saved = readdirSync(folder).map(name => JSON.parse(readFileSync(join(folder, name), 'utf8'))).find(row => row.path === '/api/unknown-command') + assert.equal(saved.stage, 'UNKNOWN_RESULT'); assert.equal(saved.body.idempotencyKey, 'original-unknown'); assert.equal(saved.body.recordVersion, '0') + assert.equal(report.createdResources.fleetWriteBoundary.status, 'STOPPED') + } finally { assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-cross-contract-')); rmSync(root, { recursive: true, force: true }) } +}) diff --git a/scripts/fleet-same-route-cost-test-env.mjs b/scripts/fleet-same-route-cost-test-env.mjs index 59a212b29..970476ead 100644 --- a/scripts/fleet-same-route-cost-test-env.mjs +++ b/scripts/fleet-same-route-cost-test-env.mjs @@ -1,119 +1,291 @@ import fs from 'node:fs' import {createHash} from 'node:crypto' -import {resolve} from 'node:path' -import {pathToFileURL} from 'node:url' +import {resolve,dirname} from 'node:path' +import {fileURLToPath,pathToFileURL} from 'node:url' +import {AcceptanceRun} from './admin-test-env-acceptance.mjs' +import {redactEvidence} from './fba-core-regression-test-env.mjs' import {parseCrossCustomerArguments,runCrossCustomer} from './fleet-cross-customer-test-env.mjs' -import {controlledPalletMeasurements} from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import {fixtureContract} from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' +import {version} from './admin-test-env-modules/grouped-dispatch-acceptance.mjs' import {verifyFleetExternalReceipt} from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' import {verifyFleetExternalDispatch} from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' import {expectedAllocations} from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' const ROOT='/api/platform/v1/last-leg',PDA='/api/pda/v1/last-leg/dispatches',MODULE='fleet-same-route-cost' -const id=v=>String(v??''),demand=(value,message)=>{if(!value)throw Error(message)} -const sorted=v=>Array.isArray(v)?v.map(sorted):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sorted(v[k])])):v +const REPO=resolve(dirname(fileURLToPath(import.meta.url)),'..') +const demand=(value,message)=>{if(!value)throw Error(message)} +const id=value=>{demand(typeof value!=='number'||Number.isSafeInteger(value),'UNSAFE_ID');return String(value??'')} +const integer=value=>{demand(['number','string'].includes(typeof value)&&/^(0|[1-9]\d*)$/.test(id(value))&&Number.isSafeInteger(Number(value)),'INVALID_QUANTITY');return Number(value)} +const sorted=value=>Array.isArray(value)?value.map(sorted):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,sorted(value[key])])):value const same=(a,b)=>JSON.stringify(sorted(a))===JSON.stringify(sorted(b)) -const cents=v=>{demand(/^\d+(?:\.\d{1,2})?$/.test(id(v)),'INVALID_MONEY');const [a,b='']=id(v).split('.');return BigInt(a)*100n+BigInt(b.padEnd(2,'0'))} +const cents=value=>{const text=String(value);demand(/^\d+(?:\.\d{1,2})?$/.test(text),'INVALID_MONEY');const [whole,fraction='']=text.split('.');return BigInt(whole)*100n+BigInt(fraction.padEnd(2,'0'))} +const encode=encodeURIComponent +const actualValue=(row,field)=>row?.evidence?.some(e=>e.field===field&&e.actual===true&&e.sourceType&&e.sourceBusinessNo&&Number.isFinite(Date.parse(e.observedAt))&&Number(e.value)===Number(row[field])) + +// Capacity planning may precede picking. Read native physical pallets and their actual evidence; +// never pass capacity-plan cargo to the loading helper or supply invented picked counts. +export function sameRouteMeasurements(context,fixture,trip,preview,{external=false}={}) { + demand(preview?.dispatchNo===fixture.dispatchNo&&preview.status==='ACTIVE'&&preview.pallets?.length,'CURRENT_NATIVE_PHYSICAL_PREVIEW_REQUIRED') + const supplied=fixture.loadingMeasurements + demand(!supplied||supplied.environment==='TEST'&&supplied.marker===context.marker&&supplied.dispatchNo===fixture.dispatchNo&&Array.isArray(supplied.pallets),'CURRENT_TEST_MEASUREMENT_RECEIPT_REQUIRED') + const actual=structuredClone(supplied?.pallets||[]),receipt=external?trip.externalReceipt?.finalBooking?.receipt:null + demand(new Set(preview.pallets.map(p=>id(p.warehousePalletId))).size===preview.pallets.length&&preview.pallets.every(p=>id(p.warehousePalletId)&&p.palletNo),'CURRENT_NATIVE_PHYSICAL_PALLET_IDENTITY_REQUIRED') + if(external){ + demand(trip.externalReceipt?.status==='VERIFIED'&&receipt?.operatorId&&Number.isFinite(Date.parse(receipt.confirmedAt))&&receipt.source?.incomingNo,'ACTUAL_EXTERNAL_MEASUREMENT_REQUIRED') + for(const measured of receipt.pallets||[]){ + const source=receipt.source.pallets?.find(p=>id(p.warehousePalletId)===id(measured.warehousePalletId)) + demand(source,'ACTUAL_EXTERNAL_MEASUREMENT_REQUIRED') + actual.push({warehousePalletId:id(measured.warehousePalletId),palletSpaces:measured.palletSpaces,weightKg:measured.weightKg??source.weightKg,volumeCbm:measured.volumeCbm??source.volumeCbm, + deliveryIndex:trip.stops.findIndex(stop=>stop.destinationKey===trip.externalReceipt.finalBooking.cargo.destinationKey), + evidence:['palletSpaces','weightKg','volumeCbm'].map(field=>({field,value:field==='palletSpaces'?measured[field]:measured[field]??source[field],actual:true, + sourceType:'FLEET_RECEIPT_CONFIRMATION',sourceBusinessNo:receipt.source.incomingNo,observedAt:receipt.confirmedAt}))}) + } + } + demand(new Set(actual.map(p=>id(p.warehousePalletId))).size===actual.length,'DUPLICATE_ACTUAL_MEASUREMENT') + const expected=new Set([...preview.pallets.map(p=>id(p.warehousePalletId)),...(receipt?.source?.pallets||[]).map(p=>id(p.warehousePalletId))]) + demand(actual.every(p=>expected.has(id(p.warehousePalletId))),'FOREIGN_ACTUAL_MEASUREMENT') + const pallets=[...expected].map(palletId=>{ + const physical=preview.pallets.find(p=>id(p.warehousePalletId)===palletId),suppliedRow=actual.find(p=>id(p.warehousePalletId)===palletId),output={warehousePalletId:palletId,evidence:[]} + const sourceBusinessNo=fixture.members?.find(m=>id(m.palletId)===palletId)?.incomingNo||(receipt?.source?.pallets?.some(p=>id(p.warehousePalletId)===palletId)?receipt.source.incomingNo:null) + demand(sourceBusinessNo,'CURRENT_MEASUREMENT_SOURCE_REQUIRED') + for(const field of ['palletSpaces','weightKg','volumeCbm']){ + const row=physical?.[field]==null?suppliedRow:physical,value=row?.[field] + demand(Number.isFinite(Number(value))&&Number(value)>0&&actualValue(row,field),'ACTUAL_LOADING_MEASUREMENT_REQUIRED:'+field) + demand(actualValue({...row,evidence:row.evidence.filter(e=>e.sourceBusinessNo===sourceBusinessNo)},field),'CURRENT_MEASUREMENT_SOURCE_REQUIRED') + demand(field!=='palletSpaces'||Number.isSafeInteger(value),'ACTUAL_PALLET_SPACES_MUST_BE_INTEGER') + demand(physical?.[field]==null||suppliedRow?.[field]==null||Number(physical[field])===Number(suppliedRow[field]),'ACTUAL_MEASUREMENT_CONFLICT') + output[field]=value;output.evidence.push(...row.evidence.filter(e=>e.field===field)) + } + output.deliveryIndex=physical?.deliveryIndex??suppliedRow?.deliveryIndex + demand(Number.isSafeInteger(output.deliveryIndex)&&output.deliveryIndex>0&&trip.stops?.[output.deliveryIndex]?.destinationKey,'ACTUAL_UNLOADING_STOP_REQUIRED') + demand(physical?.deliveryIndex==null||suppliedRow?.deliveryIndex==null||physical.deliveryIndex===suppliedRow.deliveryIndex,'ACTUAL_UNLOADING_STOP_CONFLICT') + return output + }) + return {environment:'TEST',marker:context.marker,dispatchNo:fixture.dispatchNo,pallets} +} export function assertSameRouteBasis(departure,shares) { const load=departure?.snapshot?.confirmedLoad,stops=departure?.snapshot?.trip?.stops - demand(departure?.action==='DEPART'&&load?.manifestHash,'ACTUAL_DEPART_HISTORY_REQUIRED') - demand(shares?.length===3&&shares.filter(x=>x.sourceType==='OWN').length===2&&shares.filter(x=>x.sourceType==='EXTERNAL').length===1,'THREE_EXACT_COST_SOURCES_REQUIRED') - demand(new Set(shares.map(x=>x.businessNo)).size===3&&new Set(shares.map(x=>x.dispatchNo)).size===3&&shares.every(x=>x.palletSpaces===1&&x.warehousePalletIds?.length===1),'THREE_INDEPENDENT_STANDARD_PALLETS_REQUIRED') - const ids=shares.flatMap(x=>x.warehousePalletIds).map(id) - demand(new Set(ids).size===3&&load?.pallets?.length===3&&same([...ids].sort(),load.pallets.map(x=>id(x.warehousePalletId)).sort()),'COMPLETE_DEPART_PALLET_SCOPE_REQUIRED') - demand(load.cargo?.length===3&&new Set(load.pallets.map(x=>x.deliveryIndex)).size===1&&load.pallets.every(x=>x.deliveryIndex>0&&stops?.[x.deliveryIndex]?.destinationKey),'SINGLE_REAL_UNLOADING_STOP_REQUIRED') + demand(departure?.action==='DEPART'&&load?.manifestHash,'ACTUAL_DEPART_HISTORY_REQUIRED');version(departure.recordVersion) + demand(shares?.length===2&&shares.filter(s=>s.sourceType==='OWN').length===1&&shares.filter(s=>s.sourceType==='EXTERNAL').length===1,'TWO_ACTUAL_BUSINESS_COST_SOURCES_REQUIRED') + const own=shares.find(s=>s.sourceType==='OWN'),external=shares.find(s=>s.sourceType==='EXTERNAL') + demand(own.dispatchNo&&own.businessNo===own.dispatchNo&&external.dispatchNo===own.dispatchNo&&external.businessNo!==own.businessNo,'ONE_NATIVE_MAIN_TWO_BUSINESS_IDENTITIES_REQUIRED') + demand(own.warehousePalletIds?.length===2&&external.warehousePalletIds?.length===1&&shares.every(s=>Number.isSafeInteger(s.palletSpaces)&&s.palletSpaces>0),'THREE_ACTUAL_PHYSICAL_PALLETS_REQUIRED') + const ids=shares.flatMap(s=>s.warehousePalletIds).map(id) + demand(new Set(ids).size===3&&load.pallets?.length===3&&same([...ids].sort(),load.pallets.map(p=>id(p.warehousePalletId)).sort()),'COMPLETE_DEPART_PALLET_SCOPE_REQUIRED') + demand(load.cargo?.length>=3&&new Set(load.pallets.map(p=>p.deliveryIndex)).size===1&&load.pallets.every(p=>Number.isSafeInteger(p.deliveryIndex)&&p.deliveryIndex>0&&stops?.[p.deliveryIndex]?.destinationKey),'SINGLE_REAL_UNLOADING_STOP_REQUIRED') + demand(load.cargo.every(c=>ids.includes(id(c.warehousePalletId))),'FOREIGN_DEPART_CARGO') for(const share of shares){ - const pallet=load.pallets.find(x=>id(x.warehousePalletId)===id(share.warehousePalletIds[0])),cargo=load.cargo.filter(x=>id(x.warehousePalletId)===id(pallet.warehousePalletId)) - demand(Number(pallet.palletSpaces)===1&&cargo.length===1&&cargo[0].dispatchNo===share.dispatchNo&&id(cargo[0].customerId)===share.customerId&&Number(cargo[0].packages)===1&&cargo[0].destinationKey===stops[pallet.deliveryIndex].destinationKey,'SOURCE_AND_DESTINATION_MISMATCH') + const pallets=load.pallets.filter(p=>share.warehousePalletIds.map(id).includes(id(p.warehousePalletId))) + demand(pallets.every(p=>Number.isSafeInteger(p.palletSpaces)&&p.palletSpaces>0)&&pallets.reduce((sum,p)=>sum+p.palletSpaces,0)===share.palletSpaces,'ACTUAL_CAPACITY_SUM_REQUIRED') + for(const pallet of pallets){ + const cargo=load.cargo.filter(c=>id(c.warehousePalletId)===id(pallet.warehousePalletId)) + demand(cargo.length>0&&cargo.every(c=>c.dispatchNo===share.dispatchNo&&id(c.customerId)&&integer(c.packages)>0&&c.destinationKey===stops[pallet.deliveryIndex].destinationKey) + &&cargo.reduce((sum,c)=>sum+integer(c.packages),0)===1&&new Set(cargo.map(c=>id(c.customerId))).size===1,'SOURCE_AND_DESTINATION_MISMATCH') + } } - const externals=(departure.snapshot.externalBookings||[]).filter(x=>!['DRAFT','CANCELLED'].includes(x.status)),external=shares.find(x=>x.sourceType==='EXTERNAL'),booking=externals[0] - demand(externals.length===1&&booking?.status==='BOUND'&&booking.bookingNo===external.businessNo&&booking.dispatchNo===external.dispatchNo&&id(booking.customerId)===external.customerId&&Number(booking.receipt?.source?.receivedPackages)===1&&booking.receipt.source.pallets?.length===1&&Number(booking.receipt.source.pallets[0].packages)===1&&id(booking.receipt.source.pallets[0].warehousePalletId)===id(external.warehousePalletIds[0]),'ACTUAL_EXTERNAL_RECEIPT_REQUIRED') - demand(shares.filter(x=>x.sourceType==='OWN').every(x=>x.businessNo===x.dispatchNo),'OWN_DISPATCH_IDENTITY_REQUIRED') + const ownCustomers=new Set(load.cargo.filter(c=>own.warehousePalletIds.map(id).includes(id(c.warehousePalletId))).map(c=>id(c.customerId))) + demand(ownCustomers.size===2&&same([...ownCustomers].sort(),own.customerIds?.map(id).sort())&&ownCustomers.has(id(external.customerId)),'TWO_REAL_OWN_CUSTOMERS_REQUIRED') + const externals=(departure.snapshot.externalBookings||[]).filter(b=>!['DRAFT','CANCELLED'].includes(b.status)),booking=externals[0] + const accepted=booking?.receipt,physical=accepted?.source?.pallets + demand(externals.length===1&&booking.status==='BOUND'&&booking.bookingNo===external.businessNo&&booking.dispatchNo===external.dispatchNo&&id(booking.customerId)===id(external.customerId) + &&integer(accepted?.source?.receivedPackages)===1&&physical?.length===1&&integer(physical[0].packages)===1&&id(physical[0].warehousePalletId)===id(external.warehousePalletIds[0]) + &&load.cargo.filter(c=>id(c.warehousePalletId)===id(physical[0].warehousePalletId)).every(c=>id(c.customerId)===id(booking.customerId)),'ACTUAL_EXTERNAL_RECEIPT_REQUIRED') + demand(accepted.pallets?.length===1&&id(accepted.pallets[0].warehousePalletId)===id(physical[0].warehousePalletId)&&accepted.pallets[0].palletSpaces===external.palletSpaces,'ACTUAL_EXTERNAL_CAPACITY_REQUIRED') return load } + +export function sameRouteShares(departure,fixture,receipt,bundle) { + demand(bundle?.detail?.dispatchNo===fixture.dispatchNo&&bundle.items?.dispatchNo===fixture.dispatchNo&&id(version(bundle.detail.recordVersion))===id(version(bundle.items.recordVersion)),'NATIVE_DETAIL_ITEMS_REQUIRED') + const members=fixture.members,externalId=id(receipt.warehousePalletId),ownIds=members.map(m=>id(m.palletId)) + const items=bundle.items.items,load=departure?.snapshot?.confirmedLoad + demand(items?.length===3&&new Set(items.map(i=>id(i.warehousePalletId))).size===3&&members.every(m=>items.some(i=>id(i.warehousePalletId)===id(m.palletId)&&id(i.customerId)===id(m.customerId)&&i.inboundCode===m.incomingNo&&integer(i.scheduledPackages)===1)) + &&items.some(i=>id(i.warehousePalletId)===externalId&&id(i.customerId)===id(receipt.createRequest.customerId)&&i.inboundCode===receipt.incomingNo&&integer(i.scheduledPackages)===1),'CURRENT_THREE_SOURCE_ITEMS_REQUIRED') + demand(load?.pallets?.length===3,'ACTUAL_DEPART_HISTORY_REQUIRED') + const spaces=ids=>load.pallets.filter(p=>ids.includes(id(p.warehousePalletId))).reduce((sum,p)=>{demand(Number.isSafeInteger(p.palletSpaces)&&p.palletSpaces>0,'ACTUAL_CAPACITY_SUM_REQUIRED');return sum+p.palletSpaces},0) + const shares=[{businessNo:fixture.dispatchNo,dispatchNo:fixture.dispatchNo,sourceType:'OWN',customerIds:members.map(m=>id(m.customerId)),warehousePalletIds:ownIds,palletSpaces:spaces(ownIds)}, + {businessNo:receipt.bookingNo,dispatchNo:fixture.dispatchNo,sourceType:'EXTERNAL',customerId:id(receipt.createRequest.customerId),warehousePalletIds:[externalId],palletSpaces:spaces([externalId])}] + assertSameRouteBasis(departure,shares);return shares +} + export function assertSameRouteCost(summary,departure,shares,amount='1.00') { const load=assertSameRouteBasis(departure,shares) demand(summary?.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&summary.allocationBlockReason==null&&id(summary.departureVersion)===id(departure.recordVersion)&&summary.manifestHash===load.manifestHash,'IMMUTABLE_DEPART_BASIS_REQUIRED') demand(summary.totals?.length===1&&summary.totals[0].currency==='USD','SINGLE_ORIGINAL_CURRENCY_REQUIRED') - const total=summary.totals[0],expected=expectedAllocations(amount,shares),actual=[...(total.allocations||[])].sort((a,b)=>a.businessNo.localeCompare(b.businessNo)).map(x=>({businessNo:x.businessNo,sourceType:x.sourceType,dispatchNo:x.dispatchNo,palletSpaces:Number(x.palletSpaces),cents:String(cents(x.amount))})) + const total=summary.totals[0],expected=expectedAllocations(amount,shares),actual=[...(total.allocations||[])].sort((a,b)=>a.businessNob.businessNo?1:0).map(s=>({businessNo:s.businessNo,sourceType:s.sourceType,dispatchNo:s.dispatchNo,palletSpaces:integer(s.palletSpaces),cents:String(cents(s.amount))})) demand(cents(total.directCost)===cents(amount)&&same(actual,expected),'EXACT_LARGEST_REMAINDER_ALLOCATION_REQUIRED') - demand(cents(total.ownCost)===expected.filter(x=>x.sourceType==='OWN').reduce((n,x)=>n+BigInt(x.cents),0n)&¢s(total.externalCost)===expected.filter(x=>x.sourceType==='EXTERNAL').reduce((n,x)=>n+BigInt(x.cents),0n)&¢s(total.ownCost)+cents(total.externalCost)===cents(amount),'OWN_EXTERNAL_CENT_CONSERVATION_REQUIRED') + demand(cents(total.ownCost)===expected.filter(s=>s.sourceType==='OWN').reduce((sum,s)=>sum+BigInt(s.cents),0n)&¢s(total.externalCost)===expected.filter(s=>s.sourceType==='EXTERNAL').reduce((sum,s)=>sum+BigInt(s.cents),0n)&¢s(total.ownCost)+cents(total.externalCost)===cents(amount),'OWN_EXTERNAL_CENT_CONSERVATION_REQUIRED') return expected } +// Explicit test fees belong to each actual own customer/destination group. The +// received external pallet shares its customer's group and retains its own quote. +export function sameRouteArrangement(context,fixture) { + demand(context.marker===fixture.marker&&fixture.dispatchNo&&fixture.members?.length===2&&new Set(fixture.members.map(m=>id(m.customerId))).size===2 + &&fixture.members.every(m=>m.marker===context.marker&&m.dispatchNo===fixture.dispatchNo&&/^[1-9]\d*$/.test(id(m.customerId))&&m.destination?.key?.startsWith('FBA:')) + &&new Set(fixture.members.map(m=>m.destination.key)).size===1,'CURRENT_TWO_CUSTOMER_DESTINATION_GROUPS_REQUIRED') + return {mode:'NATIVE',marker:context.marker,dispatchNo:fixture.dispatchNo,plannedDeliveryAt:new Date(Date.now()+21600000).toISOString(), + deliveryMethod:'TRUCK_PALLET',pickupMethod:'WAREHOUSE_PICKUP',loadingType:'PALLET',appointmentAccount:'SELF',vehicleType:'TRUCK',remark:`${context.marker} TEST synthetic customer delivery fees`, + groups:fixture.members.map(m=>({customerId:id(m.customerId),destinationKey:m.destination.key, + fees:[{direction:'RECEIVABLE',feeType:'DELIVERY_FEE',feeName:'本轮合成测试派送费',currency:'CNY',amount:'30.00'}]}))} +} + +// Unknown writes stop this proof and all dispatch helpers sharing the context. +export function sameRouteIo(context,proof) { + demand(typeof context.checkpoint==='function','DURABLE_CHECKPOINT_REQUIRED') + context.createdResources.dispatchWriteBoundary??={stopped:false} + const boundary=context.createdResources.dispatchWriteBoundary + const check=(label,condition)=>{context.assert(MODULE,label,null,Boolean(condition),'',{scenario:MODULE,deploymentBlocker:true});demand(condition,label)} + const send=async(method,path,body,token=context.token,multipart=false)=>{ + const write=!['GET','HEAD'].includes(method) + demand(!write||!boundary.stopped,'UNKNOWN_WRITE_RESULT_STOPPED') + const entry={method,path,body:multipart?{name:body.name,type:body.type,sizeBytes:body.content.byteLength}:structuredClone(body),status:'SENDING'} + proof.requests.push(entry) + try{await context.checkpoint()}catch(error){boundary.stopped=true;entry.status='NOT_SENT';throw error} + let response + try{response=await (multipart?context.requestMultipart(method,path,body,token):context.request(method,path,body,token))} + catch(error){entry.status='UNKNOWN_RESULT';if(write){boundary.stopped=true;boundary.outcomeUnknown=true;proof.outcomeUnknown=true}await context.checkpoint();throw error} + const unknown=write&&(!response?.http||response.http>=500||response.body?.code==null||String(response.body.code)==='200'&&response.body.data==null) + Object.assign(entry,{status:unknown?'UNKNOWN_RESULT':'RESPONDED',http:response?.http,code:response?.body?.code,requestId:response?.requestId,traceId:response?.traceId,result:structuredClone(response?.body?.data)}) + if(unknown){boundary.stopped=true;boundary.outcomeUnknown=true;proof.outcomeUnknown=true} + try{await context.checkpoint()}catch(error){if(write)boundary.stopped=true;throw error} + check(`${method} ${path}`,!unknown&&response?.http===200&&id(response.body?.code)==='200'&&response.requestId&&response.traceId) + return response.body.data + } + return {check,request:send,upload:(path,body)=>send('POST',path,body,context.token,true)} +} + export async function verifySameRouteCost(context,fixture,{execute=false,readInventory}={}) { if(!execute)return {scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',businessSqlWrites:false} - demand(context?.marker&&context.marker===fixture?.marker&&fixture?.pdaToken&&fixture?.members?.length===2 - &&context.createdResources?.fleetTrips?.some(x=>x.tripNo===fixture.tripNo)&&typeof readInventory==='function','CURRENT_TWO_CUSTOMER_FIXTURE_REQUIRED') + demand(context?.marker===fixture?.marker&&context?.marker&&fixture.stage===fixtureContract.stage&&fixture.dispatchNo&&fixture.pdaToken&&fixture.members?.length===2 + &&fixture.members.every(m=>m.marker===context.marker&&m.dispatchNo===fixture.dispatchNo&&m.receiptVerified===true)&&new Set(fixture.members.map(m=>id(m.customerId))).size===2 + &&fixture.tripProof?.dispatchNo===fixture.dispatchNo&&context.createdResources?.fleetTrips?.includes(fixture.tripProof)&&typeof readInventory==='function','CURRENT_TWO_CUSTOMER_FIXTURE_REQUIRED') demand(!context.createdResources.fleetSameRouteCost,'SAME_ROUTE_VERIFICATION_ALREADY_STARTED') - const proof=context.createdResources.fleetSameRouteCost={status:'CHECKING',tripNo:fixture.tripNo,marker:context.marker,scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',snapshots:{},requests:[]} - const check=(name,condition)=>{context.assert(MODULE,name,null,Boolean(condition),'',{scenario:MODULE,deploymentBlocker:true});demand(condition,name)} - const request=async(method,path,body,token=context.token)=>{const r=await context.request(method,path,body,token);proof.requests.push({method,path,http:r.http,code:r.body?.code,requestId:r.requestId,traceId:r.traceId});check(`${method} ${path}`,r.http===200&&id(r.body?.code)==='200'&&r.requestId&&r.traceId);return r.body.data} - const trip=context.createdResources.fleetTrips.find(x=>x.tripNo===fixture.tripNo),base=`${ROOT}/fleet/trips/${fixture.tripNo}`,key=suffix=>`${context.marker}-SAME-${suffix}` - const inventory=async label=>{const rows=[];for(const customerId of [...new Set(fixture.members.map(x=>x.customerId))])rows.push(await readInventory({marker:context.marker,customerId,warehouseId:fixture.warehouseId}));proof.snapshots[label]=rows;return rows} + const proof=context.createdResources.fleetSameRouteCost={status:'CHECKING',tripNo:fixture.tripNo,dispatchNo:fixture.dispatchNo,marker:context.marker,scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',snapshots:{},requests:[]} + const {check,request,upload}=sameRouteIo(context,proof),trip=fixture.tripProof,base=`${ROOT}/fleet/trips/${encode(fixture.tripNo)}`,path=`${ROOT}/dispatches/${encode(fixture.dispatchNo)}`,pdaPath=`${PDA}/${encode(fixture.dispatchNo)}`,key=suffix=>`${context.marker}-SAME-${suffix}` + const inventory=async label=>{ + const rows=[] + for(const customerId of [...new Set(fixture.members.map(m=>id(m.customerId)))]){ + const scope={marker:context.marker,customerId,warehouseId:id(fixture.warehouseId)},raw=await readInventory(scope) + check('Inventory reader proves complete current customer and warehouse',same(raw?.scope,{...scope,coverage:'CUSTOMER_WAREHOUSE_COMPLETE'})) + for(const collection of ['balances','positions','ledger'])check('Raw customer inventory has unique owned rows',Array.isArray(raw[collection])&&new Set(raw[collection].map(r=>id(r.id))).size===raw[collection].length&&raw[collection].every(r=>id(r.customerId)===customerId&&id(r.overseasWarehouseId)===scope.warehouseId)) + for(const balance of raw.balances){ + for(const field of ['inTransitPackages','procurementPackages'])integer(balance[field]) + for(const field of ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages'])check('All raw position buckets conserve their balance',integer(balance[field])===raw.positions.filter(p=>id(p.balanceId)===id(balance.id)).reduce((sum,p)=>sum+integer(p[field]),0)) + } + check('No orphan physical positions',raw.positions.every(p=>raw.balances.some(b=>id(b.id)===id(p.balanceId)))) + rows.push(raw) + } + proof.snapshots[label]=rows;await context.checkpoint();return rows + } try{ - let detail=await request('GET',base),plan=await request('GET',`${base}/capacity-plan`) - check('Two owned sources have one real FBA unloading destination',plan.cargo?.length===2&&new Set(plan.cargo.map(x=>x.destinationKey)).size===1&&plan.cargo.every(x=>x.destinationKey?.startsWith('FBA:'))) - await request('POST',`${base}/capacity-plan`,{recordVersion:plan.recordVersion,manifestHash:plan.manifestHash,pallets:controlledPalletMeasurements(plan.cargo,detail.trip.stops),reason:context.marker}) + const detail=await request('GET',base),plan=await request('GET',`${base}/capacity-plan`),physical=await request('GET',`${path}/loading`) + check('Two own physical sources share one native dispatch and FBA stop',detail.dispatches?.length===1&&detail.dispatches[0].dispatchNo===fixture.dispatchNo&&plan.cargo?.length===2&&plan.cargo.every(c=>c.dispatchNo===fixture.dispatchNo)&&new Set(plan.cargo.map(c=>c.destinationKey)).size===1&&plan.cargo.every(c=>c.destinationKey?.startsWith('FBA:'))) + check('Capacity uses exactly both current receipt pallets before any scanning',physical.pallets?.length===2&&fixture.members.every(m=>physical.pallets.some(p=>id(p.warehousePalletId)===id(m.palletId)&&p.palletNo===m.palletNo&&integer(p.scheduledPackages)===1&&integer(p.pickedPackages)===0))) + trip.stops=detail.trip.stops + const measured=sameRouteMeasurements(context,fixture,trip,physical) + proof.capacityRequest={recordVersion:version(plan.recordVersion),manifestHash:plan.manifestHash,pallets:measured.pallets.map(({evidence,...p})=>p),reason:context.marker} + const planned=await request('POST',`${base}/capacity-plan`,proof.capacityRequest) + check('Actual capacity plan is valid',planned.planValid===true) await verifyFleetExternalReceipt(context,MODULE,trip,fixture.pdaToken) - await verifyFleetExternalDispatch(context,MODULE,trip) + const beforePick=await inventory('BEFORE_PICK') + const measurements=sameRouteMeasurements(context,fixture,trip,await request('GET',`${path}/loading`),{external:true}) + proof.arrangementSource=sameRouteArrangement(context,fixture);await context.checkpoint() + await verifyFleetExternalDispatch(context,MODULE,trip,{pickerToken:fixture.pdaToken,actualMeasurements:measurements,arrangement:proof.arrangementSource}) const receipt=trip.externalReceipt,external=trip.externalDispatch - const members=proof.members=[...fixture.members,{customerId:external.customerId,dispatchNo:external.dispatchNo,outboundNo:external.outboundNo,deliveryNo:external.deliveryNo,palletId:receipt.warehousePalletId,palletNo:receipt.palletNo,positionId:id(receipt.finalInventory.positions[0].id)}] - check('Only three distinct current-run dispatches and pallets',new Set(members.map(x=>x.dispatchNo)).size===3&&new Set(members.map(x=>x.palletId)).size===3) - for(const[index,m]of members.entries()){ - const task=await request('GET',`${PDA}/${m.dispatchNo}`,undefined,fixture.pdaToken),item=task.items?.[0] - check('PDA item is the original one-box pallet and source position',task.tripNo===fixture.tripNo&&task.items.length===1&&item.palletNo===m.palletNo&&Number(item.scheduledPackages)===1&&Number(item.pickedPackages)===0&&item.positions?.some(x=>id(x.id)===m.positionId&&Number(x.availablePackages)===1)) - const pick={positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker} - await request('POST',`${PDA}/${m.dispatchNo}/items/${item.dispatchItemId}/pick`,pick,fixture.pdaToken) - await request('POST',`${PDA}/${m.dispatchNo}/complete`,{idempotencyKey:key(`PICK-COMPLETE-${index}`)},fixture.pdaToken) + check('Actual external source is added to the same original main',receipt?.status==='VERIFIED'&&external?.status==='VERIFIED'&&external.dispatchNo===fixture.dispatchNo) + const members=proof.members=[...fixture.members.map(m=>({...m})),{customerId:id(external.customerId),dispatchNo:fixture.dispatchNo,incomingNo:receipt.incomingNo,palletId:id(receipt.warehousePalletId),palletNo:receipt.palletNo, + positionId:id(receipt.finalInventory.positions[0].id),balanceId:id(receipt.finalInventory.positions[0].balanceId)}] + check('Exactly three distinct original physical pallets and positions',new Set(members.map(m=>id(m.palletId))).size===3&&new Set(members.map(m=>id(m.positionId))).size===3) + const stock=await inventory('PICKED') + for(const member of members){ + const before=beforePick.find(s=>id(s.scope.customerId)===id(member.customerId)),after=stock.find(s=>id(s.scope.customerId)===id(member.customerId)) + check('Original one-box position alone is depleted by scanning',before.positions.some(p=>id(p.id)===member.positionId&&integer(p.availablePackages)===1)&&after.positions.some(p=>id(p.id)===member.positionId&&integer(p.availablePackages)===0)) + const shipped=after.ledger.filter(r=>!before.ledger.some(old=>id(old.id)===id(r.id))&&r.businessType==='SHIP'&&id(r.positionId)===member.positionId) + check('Exactly one actual SHIP per original position',shipped.length===1&&shipped[0].businessOrderNo===fixture.dispatchNo&&Number(shipped[0].deltaPackages)===-1) } - const stock=await inventory('PICKED'),positions=stock.flatMap(x=>x.positions) - check('All three original physical positions remain with zero available stock',positions.length===3&&members.every(m=>positions.some(x=>id(x.id)===m.positionId&&Number(x.availablePackages)===0))) - const loading=await request('GET',`${base}/loading`);detail=await request('GET',base) - check('Three real pallets ready for loading',!loading.blockReason&&loading.cargo.length===3) - const measured=await request('POST',`${base}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:controlledPalletMeasurements(loading.cargo,detail.trip.stops),reason:context.marker}) - const approved=await request('POST',`${base}/loading-review`,{recordVersion:measured.recordVersion,decision:'APPROVED'}) - const depart={recordVersion:approved.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker},departPath=`${PDA}/${members[0].dispatchNo}/depart` - proof.departure=await request('POST',departPath,depart,fixture.pdaToken) - check('Shared actual departure contains all three customer executions',proof.departure.tripNo===fixture.tripNo&&proof.departure.status==='IN_TRANSIT'&&Number(proof.departure.shippedPackages)===3&&same(proof.departure.dispatches.map(x=>x.dispatchNo).sort(),members.map(x=>x.dispatchNo).sort())) - const departedTrip=proof.departedTrip=await request('GET',base),replay=await request('POST',departPath,depart,fixture.pdaToken) - check('Departure replay is explicit and does not change complete trip or stock',replay.replayed===true&&same(departedTrip,await request('GET',base))&&same(stock,await inventory('DEPARTED'))) + for(const before of beforePick){ + const after=stock.find(s=>id(s.scope.customerId)===id(before.scope.customerId)),owned=members.filter(m=>id(m.customerId)===id(before.scope.customerId)),oldIds=new Set(before.ledger.map(r=>id(r.id))) + check('All original immutable inventory movements remain exact',same(before.ledger,after.ledger.filter(r=>oldIds.has(id(r.id))))) + const added=after.ledger.filter(r=>!oldIds.has(id(r.id))) + check('Only one ALLOCATE PICK SHIP sequence exists per physical source',added.length===owned.length*3&&owned.every(m=>['ALLOCATE','PICK','SHIP'].every(type=>added.filter(r=>r.businessType===type&&id(r.positionId)===m.positionId&&r.businessOrderNo===fixture.dispatchNo&&Number(r.deltaPackages)===(type==='SHIP'?-1:1)).length===1))) + const quantities=raw=>Object.fromEntries(['balances','positions'].map(collection=>[collection,raw[collection].map(row=>({id:id(row.id),...Object.fromEntries(['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages',...(collection==='balances'?['inTransitPackages','procurementPackages']:[])].map(field=>[field,integer(row[field])]))}))])) + const expected=structuredClone(before) + for(const member of owned){ + expected.positions.find(p=>id(p.id)===member.positionId).availablePackages=0 + const balance=expected.balances.find(b=>id(b.id)===member.balanceId);balance.availablePackages=integer(balance.availablePackages)-1 + } + check('Exactly each own customer position and balance loses its single physical box',same(quantities(expected),quantities(after))) + } + const current={detail:await request('GET',path),items:await request('GET',`${path}/items`)},ready=await request('GET',pdaPath,undefined,fixture.pdaToken) + check('All three boxes are loaded and arranged before native departure',current.detail.status==='ACTIVE'&¤t.detail.stage==='AFTER_LOADING'&¤t.detail.pickingReconciled===true&&integer(current.detail.counts.packageCount)===3&&integer(current.detail.counts.pickedPackageCount)===3&&integer(current.detail.counts.shippedPackageCount)===0 + &&ready.dispatchNo===fixture.dispatchNo&&ready.status==='ACTIVE'&&ready.stage==='AFTER_LOADING'&&ready.pickingReconciled===true&&id(ready.warehouseId)===id(fixture.warehouseId)&&id(version(ready.recordVersion))===id(version(current.detail.recordVersion))&&ready.actions?.DEPART?.allowed===true&¤t.detail.actions?.DEPART?.allowed===true) + const deliveries=proof.deliveries=current.detail.documents?.deliveries + check('Exactly two customer-isolated deliveries cover three actual boxes',deliveries?.length===2&&new Set(deliveries.map(d=>d.deliveryNo)).size===2&&deliveries.reduce((sum,d)=>sum+integer(d.packageCount),0)===3&&members.every(m=>deliveries.some(d=>id(d.customerId)===id(m.customerId)))) + const outbounds=current.detail.documents.outbounds + check('Each delivery belongs to exactly its real customer outbound',outbounds?.length===2&&new Set(outbounds.map(o=>o.outboundNo)).size===2&&outbounds.every(o=>deliveries.filter(d=>d.outboundNo===o.outboundNo&&id(d.customerId)===id(o.customerId)).reduce((sum,d)=>sum+integer(d.packageCount),0)===integer(o.packageCount))) + proof.departureRequest={recordVersion:version(ready.recordVersion),idempotencyKey:key('DEPART'),remark:context.marker} + proof.departure=await request('POST',`${pdaPath}/depart`,proof.departureRequest,fixture.pdaToken) + check('One native departure covers the whole current main',proof.departure.dispatchNo===fixture.dispatchNo&&proof.departure.action==='DEPART'&&proof.departure.status==='ACTIVE'&&proof.departure.stage==='DELIVERING'&&integer(proof.departure.result?.shippedPackages)===3&&BigInt(version(proof.departure.recordVersion))>BigInt(version(ready.recordVersion)) + &&proof.departure.result.deliveries?.length===deliveries.length&&deliveries.every(d=>proof.departure.result.deliveries.some(r=>r.deliveryNo===d.deliveryNo&&r.outboundNo===d.outboundNo&&id(r.customerId)===id(d.customerId)&&integer(r.packageCount)===integer(d.packageCount)))) + const departedTrip=proof.departedTrip=await request('GET',base),departed=await request('GET',path),replay=await request('POST',`${path}/depart`,proof.departureRequest) + check('Actual main and its single resource record are delivering in transit',departedTrip.trip?.status==='IN_TRANSIT'&&departedTrip.dispatches?.length===1&&departedTrip.dispatches[0].dispatchNo===fixture.dispatchNo&&departed.status==='ACTIVE'&&departed.stage==='DELIVERING'&&integer(departed.counts.shippedPackageCount)===3&&id(version(departed.recordVersion))===id(version(proof.departure.recordVersion))) + check('Platform replay returns the original PDA departure and version',replay.replayed===true&&id(version(replay.recordVersion))===id(version(proof.departure.recordVersion))&&same(replay.result,proof.departure.result)) + check('Departure and replay do not deduct stock again or mutate facts',same(departedTrip,await request('GET',base))&&same(departed,await request('GET',path))&&same(stock,await inventory('DEPARTED'))) const history=await request('GET',`${base}/history?page=1&size=200`) - check('Complete trip history contains one actual departure',Number(history.total)===history.records.length&&history.records.filter(x=>x.action==='DEPART').length===1) - const departure=proof.departureHistory=history.records.find(x=>x.action==='DEPART') - const shares=proof.shares=members.map((m,i)=>({businessNo:i===2?receipt.bookingNo:m.dispatchNo,sourceType:i===2?'EXTERNAL':'OWN',dispatchNo:m.dispatchNo,customerId:m.customerId,palletSpaces:1,warehousePalletIds:[m.palletId]})) - assertSameRouteBasis(departure,shares) + check('Complete history has one immutable actual departure',integer(history.total)===history.records.length&&history.records.filter(r=>r.action==='DEPART').length===1) + const departure=proof.departureHistory=history.records.find(r=>r.action==='DEPART'),shares=proof.shares=sameRouteShares(departure,fixture,receipt,current) const costPath=`${base}/costs`,before=await request('GET',`${costPath}?page=1&size=100`) - check('Current trip has no prior cost entries',Number(before.page.total)===0&&before.page.records.length===0&&before.summary.totals.length===0) - check('Empty cost query already uses exact original same-route departure',before.summary.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&before.summary.allocationBlockReason==null&&id(before.summary.departureVersion)===id(departure.recordVersion)&&before.summary.manifestHash===departure.snapshot.confirmedLoad.manifestHash) - proof.costRequest={idempotencyKey:key('COST-USD'),details:{category:'FUEL',currency:'USD',amount:'1.00',incurredAt:new Date().toISOString(),remark:`${context.marker} same-route synthetic cost`}} + check('Fresh trip has no prior costs',integer(before.page.total)===0&&before.page.records.length===0&&before.summary.totals.length===0) + check('Empty query already locks actual departure basis',before.summary.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&before.summary.allocationBlockReason==null&&id(before.summary.departureVersion)===id(departure.recordVersion)&&before.summary.manifestHash===departure.snapshot.confirmedLoad.manifestHash) + proof.costRequest={idempotencyKey:key('COST-USD'),details:{category:'FUEL',currency:'USD',amount:'1.00',incurredAt:new Date().toISOString(),remark:`${context.marker} TEST synthetic cost`}} const cost=proof.cost=await request('POST',costPath,proof.costRequest),result=proof.costResult=await request('GET',`${costPath}?page=1&size=100`) - check('Exactly one USD cost belongs to original trip',cost.tripNo===fixture.tripNo&&cost.costNo&&Number(cost.recordVersion)===0&&Number(result.page.total)===1&&result.page.records.length===1&&result.page.records[0].costNo===cost.costNo) + check('Exactly one original-currency cost was created at legal zero version',cost.tripNo===fixture.tripNo&&cost.costNo&&BigInt(version(cost.recordVersion))===0n&&integer(result.page.total)===1&&result.page.records.length===1&&result.page.records[0].costNo===cost.costNo) proof.expectedAllocations=assertSameRouteCost(result.summary,departure,shares) - check('Exact cost command replay returns same persisted cost and unchanged allocation',same(result.page.records[0],await request('POST',costPath,proof.costRequest))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) - const costHistory=proof.costHistory=await request('GET',`${costPath}/${cost.costNo}/history?page=1&size=100`) - check('Only one immutable cost creation history',Number(costHistory.total)===1&&costHistory.records.length===1&&costHistory.records[0].action==='CREATE'&&costHistory.records[0].snapshot?.cost?.costNo===cost.costNo) + check('Exact cost command replay returns the same persisted cost and allocation',same(result.page.records[0],await request('POST',costPath,proof.costRequest))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) + const costHistory=proof.costHistory=await request('GET',`${costPath}/${encode(cost.costNo)}/history?page=1&size=100`) + check('Exactly one immutable cost CREATE exists',integer(costHistory.total)===1&&costHistory.records.length===1&&costHistory.records[0].action==='CREATE'&&costHistory.records[0].snapshot?.cost?.costNo===cost.costNo) assertSameRouteCost(costHistory.records[0].snapshot.summary,departure,shares) - check('Cost writes preserve exact departure history and customer stock',same(history,await request('GET',`${base}/history?page=1&size=200`))&&same(stock,await inventory('COST_REPLAY'))) - for(const[index,m]of members.entries()){ - const path=`${ROOT}/delivery-orders/${m.deliveryNo}`,scope=`?customerId=${m.customerId}`,delivery=await request('GET',path+scope) - await request('PUT',`${path}/status${scope}`,{status:'DELIVERING',expectedVersion:delivery.order.version,remark:context.marker}) - const docs=await request('GET',`${path}/documents${scope}`),upload=await context.requestMultipart('POST',`${path}/documents/RECEIPT/files${scope}`,{name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${m.deliveryNo}`)}) - check('Actual receipt upload belongs to delivery',upload.http===200&&id(upload.body?.code)==='200'&&upload.body.data?.fileId) - const documents=(docs.categories||[]).filter(x=>x.category!=='RECEIPT').map(x=>({category:x.category,fileIds:(x.files||[]).map(f=>f.fileId)}));documents.push({category:'RECEIPT',fileIds:[id(upload.body.data.fileId)]}) - await request('PUT',`${path}/documents${scope}`,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents}) - check('This actual delivery completed with saved receipt',(await request('GET',path+scope)).order.status==='DELIVERED') + check('Cost writes preserve original DEPART history, main, trip and all stock',same(history,await request('GET',`${base}/history?page=1&size=200`))&&same(departed,await request('GET',path))&&same(departedTrip,await request('GET',base))&&same(stock,await inventory('COST_REPLAY'))) + for(const [index,ref]of deliveries.entries()){ + const deliveryPath=`${ROOT}/delivery-orders/${encode(ref.deliveryNo)}`,scope=`?customerId=${encode(id(ref.customerId))}`,delivery=await request('GET',deliveryPath+scope) + check('Only this customer actual delivery is signing',delivery.order?.dispatchNo===fixture.dispatchNo&&id(delivery.order.customerId)===id(ref.customerId)&&delivery.order.status==='DELIVERING') + const others=await Promise.all(deliveries.filter(d=>d.deliveryNo!==ref.deliveryNo).map(d=>request('GET',`${ROOT}/delivery-orders/${encode(d.deliveryNo)}?customerId=${encode(id(d.customerId))}`))) + const docsPath=`${deliveryPath}/documents${scope}`,docs=await request('GET',docsPath) + const uploaded=await upload(`${deliveryPath}/documents/RECEIPT/files${scope}`,{name:`${context.marker}-${index}-TEST-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} TEST synthetic receipt ${ref.deliveryNo}`)}) + check('Actual receipt file has a persisted identity',uploaded?.fileId) + const documents=(docs.categories||[]).filter(d=>d.category!=='RECEIPT').map(d=>({category:d.category,fileIds:(d.files||[]).map(f=>f.fileId)}));documents.push({category:'RECEIPT',fileIds:[id(uploaded.fileId)]}) + const body={expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents} + const signed=await request('PUT',docsPath,body),after=await request('GET',deliveryPath+scope) + check('Real saved receipt completes this delivery',after.order.status==='DELIVERED') + await request('PUT',docsPath,body) + check('Signing replay preserves complete delivery evidence',same(after,await request('GET',deliveryPath+scope))) + check('Other customer delivery is unchanged by this signature',same(others,await Promise.all(deliveries.filter(d=>d.deliveryNo!==ref.deliveryNo).map(d=>request('GET',`${ROOT}/delivery-orders/${encode(d.deliveryNo)}?customerId=${encode(id(d.customerId))}`))))) + proof.signatures??=[];proof.signatures.push({deliveryNo:ref.deliveryNo,customerId:ref.customerId,request:body,result:signed});await context.checkpoint() } + const completed=await request('GET',path) + check('All normal signatures aggregate current native main completion',completed.status==='ACTIVE'&&completed.stage==='DELIVERED') check('Signing preserves all customer stock and original cost allocation',same(stock,await inventory('SIGNED'))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) - proof.status='VERIFIED';return proof - }catch(error){proof.status='FAILED';proof.error=error.message;throw error} + proof.status='VERIFIED';await context.checkpoint();return proof + }catch(error){proof.status=proof.outcomeUnknown||context.createdResources.dispatchWriteBoundary?.outcomeUnknown?'OUTCOME_UNKNOWN':'FAILED';proof.error=error.message;await context.checkpoint();throw error} } export async function runSameRouteCost(config,{env=process.env,...dependencies}={}) { - if(!config.execute)return {...config,scope:'TWO_FRESH_OWN_RECEIPTS_PLUS_ONE_EXTERNAL_SAME_ROUTE',cost:'USD 1.00, three independently proven pallet sources, largest remainder by business number',sqlWrites:false,deploy:false,cleanup:'Retain samples; disable own PDA and close own sessions'} - const files=['scripts/fleet-same-route-cost-test-env.mjs','scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs','scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs','scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs'],sources=files.map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(path)).digest('hex')})) - return runCrossCustomer(config,{...dependencies,env,verify:async(context,fixture,options)=>{ - fs.writeFileSync(resolve('reports',`fleet-cross-${config.runId}`,'same-route-source.json'),JSON.stringify({scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',sources},null,2),{flag:'wx'}) + if(!config.execute)return {...config,scope:'TWO_FRESH_OWN_RECEIPTS_PLUS_ONE_EXTERNAL_SAME_ROUTE',cost:'USD 1.00, one OWN dispatch and one EXTERNAL booking on the same main, actual pallet-space largest remainder',sqlWrites:false,deploy:false,cleanup:'Retain samples; disable own PDA and close own sessions'} + const files=['scripts/fleet-same-route-cost-test-env.mjs','scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs','scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs','scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs','scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs'],sources=files.map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(resolve(REPO,path))).digest('hex')})) + const factory=dependencies.contextFactory|| (options=>new AcceptanceRun(options)) + let runOutput + return runCrossCustomer(config,{...dependencies,env,verificationScope:'SAME_ROUTE_COST',contextFactory:options=>{ + runOutput=options.output + const context=factory(options),prior=context.checkpoint?.bind(context) + context.checkpoint=async()=>{ + await prior?.() + const path=resolve(options.output,'same-route-progress.json'),temp=`${path}.tmp` + fs.writeFileSync(temp,JSON.stringify(redactEvidence({marker:context.marker,createdResources:context.createdResources}),null,2)+'\n');fs.renameSync(temp,path) + } + return context + },verify:async(context,fixture,options)=>{ + fs.writeFileSync(resolve(runOutput,'same-route-source.json'),JSON.stringify({scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',sources},null,2),{flag:'wx'}) return verifySameRouteCost(context,fixture,options) }}) } diff --git a/scripts/fleet-same-route-cost-test-env.test.mjs b/scripts/fleet-same-route-cost-test-env.test.mjs index d459a8e07..c768c3e33 100644 --- a/scripts/fleet-same-route-cost-test-env.test.mjs +++ b/scripts/fleet-same-route-cost-test-env.test.mjs @@ -1,40 +1,173 @@ import test from 'node:test' import assert from 'node:assert/strict' -import {assertSameRouteCost,assertSameRouteBasis,runSameRouteCost,verifySameRouteCost} from './fleet-same-route-cost-test-env.mjs' -function sample(){ - const shares=[{businessNo:'DS-A',dispatchNo:'DS-A',sourceType:'OWN',customerId:'101',palletSpaces:1,warehousePalletIds:['1']},{businessNo:'DS-B',dispatchNo:'DS-B',sourceType:'OWN',customerId:'102',palletSpaces:1,warehousePalletIds:['2']},{businessNo:'BK-C',dispatchNo:'DS-C',sourceType:'EXTERNAL',customerId:'101',palletSpaces:1,warehousePalletIds:['3']}] - const departure={action:'DEPART',recordVersion:'7',snapshot:{trip:{stops:[{name:'Origin'},{destinationKey:'FBA:A'},{destinationKey:'FBA:B'}]},confirmedLoad:{manifestHash:'real-hash',pallets:shares.map(x=>({warehousePalletId:x.warehousePalletIds[0],palletSpaces:1,deliveryIndex:1})),cargo:shares.map(x=>({warehousePalletId:x.warehousePalletIds[0],dispatchNo:x.dispatchNo,customerId:x.customerId,packages:1,destinationKey:'FBA:A'}))},externalBookings:[{bookingNo:'BK-C',dispatchNo:'DS-C',customerId:'101',status:'BOUND',receipt:{source:{receivedPackages:1,pallets:[{warehousePalletId:'3',packages:1}]}}}]}} - // Three equal independent sources: the lexical first business number receives the residual cent. - const summary={departureVersion:'7',manifestHash:'real-hash',allocationMethod:'SAME_ROUTE_PALLET_SPACES',totals:[{currency:'USD',directCost:'1.00',ownCost:'0.66',externalCost:'0.34',allocations:shares.map(x=>({businessNo:x.businessNo,dispatchNo:x.dispatchNo,sourceType:x.sourceType,palletSpaces:1,amount:x.sourceType==='OWN'?'0.33':'0.34'}))}]} - return{shares,departure,summary} +import {fixtureContract} from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' +import {assertSameRouteCost,assertSameRouteBasis,sameRouteShares,sameRouteMeasurements,sameRouteArrangement,sameRouteIo,runSameRouteCost,verifySameRouteCost} from './fleet-same-route-cost-test-env.mjs' + +function sample(ownSpaces=[1,1],externalSpaces=1){ + const shares=[{businessNo:'DS-OWN',dispatchNo:'DS-OWN',sourceType:'OWN',customerIds:['101','102'],palletSpaces:ownSpaces.reduce((sum,n)=>sum+n,0),warehousePalletIds:['9007199254740993','2']}, + {businessNo:'BK-C',dispatchNo:'DS-OWN',sourceType:'EXTERNAL',customerId:'101',palletSpaces:externalSpaces,warehousePalletIds:['3']}] + const departure={action:'DEPART',recordVersion:'9007199254740997',snapshot:{trip:{stops:[{name:'Origin'},{destinationKey:'FBA:A'},{destinationKey:'FBA:B'}]}, + confirmedLoad:{manifestHash:'real-hash',pallets:[...ownSpaces,externalSpaces].map((palletSpaces,index)=>({warehousePalletId:['9007199254740993','2','3'][index],palletSpaces,deliveryIndex:1})), + cargo:['101','102','101'].map((customerId,index)=>({warehousePalletId:['9007199254740993','2','3'][index],dispatchNo:'DS-OWN',customerId,packages:'1',destinationKey:'FBA:A'}))}, + externalBookings:[{bookingNo:'BK-C',dispatchNo:'DS-OWN',customerId:'101',status:'BOUND',receipt:{source:{receivedPackages:'1',pallets:[{warehousePalletId:'3',packages:'1'}]},pallets:[{warehousePalletId:'3',palletSpaces:externalSpaces}]}}]}} + // Independent two-business 2:1 oracle: 100 cents => OWN 67, EXTERNAL 33. + const summary={departureVersion:'9007199254740997',manifestHash:'real-hash',allocationMethod:'SAME_ROUTE_PALLET_SPACES',totals:[{currency:'USD',directCost:'1.00',ownCost:'0.67',externalCost:'0.33', + allocations:shares.map(s=>({businessNo:s.businessNo,dispatchNo:s.dispatchNo,sourceType:s.sourceType,palletSpaces:s.palletSpaces,amount:s.sourceType==='OWN'?'0.67':'0.33'}))}]} + return {shares,departure,summary} } test('offline entry does not call remote services and missing runtime scope is rejected',async()=>{ - assert.equal((await runSameRouteCost({execute:false},{env:{}})).deploy,false) - assert.equal((await verifySameRouteCost()).businessSqlWrites,false) - await assert.rejects(verifySameRouteCost({},null,{execute:true}),/FIXTURE/) + assert.equal((await runSameRouteCost({execute:false},{env:{}})).deploy,false) + assert.equal((await verifySameRouteCost()).businessSqlWrites,false) + await assert.rejects(verifySameRouteCost({},null,{execute:true}),/FIXTURE/) }) -test('exact three-source penny conservation follows real business-number tie break',()=>{ - const s=sample();assert.equal(assertSameRouteBasis(s.departure,s.shares).pallets.length,3) - assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>[x.businessNo,x.cents]),[['BK-C','34'],['DS-A','33'],['DS-B','33']]) +test('ordinary native arrangement declares exactly two actual own customer groups without a third external fee',()=>{ + const fixture={marker:'RUN',dispatchNo:'DS-OWN',members:['101','102'].map(customerId=>({marker:'RUN',dispatchNo:'DS-OWN',customerId,destination:{key:'FBA:A'}}))} + const source=sameRouteArrangement({marker:'RUN'},fixture) + assert.equal(source.mode,'NATIVE');assert.equal(source.dispatchNo,'DS-OWN');assert.equal(source.groups.length,2) + assert.deepEqual(source.groups.map(g=>[g.customerId,g.destinationKey,g.fees.length,g.fees[0].amount]),[['101','FBA:A',1,'30.00'],['102','FBA:A',1,'30.00']]) + assert.ok(!source.groups.some(g=>g.bookingNo));assert.ok(Number.isFinite(Date.parse(source.plannedDeliveryAt))) + fixture.members[1].destination.key='FBA:B';assert.throws(()=>sameRouteArrangement({marker:'RUN'},fixture),/GROUPS/) }) -for(const fault of ['wrong action','missing pallet','overlapping source','different destination','wrong customer','wrong dispatch','wrong receipt','unbound booking','uncovered external','stale version','stale hash','invented method','mixed currency','lost penny','wrong source type','wrong aggregate','wrong residual winner'])test(`rejects invalid allocation evidence: ${fault}`,()=>{ - const s=sample(),l=s.departure.snapshot.confirmedLoad,t=s.summary.totals[0],b=s.departure.snapshot.externalBookings[0] - if(fault==='wrong action')s.departure.action='PLAN' - if(fault==='missing pallet')l.pallets.pop() - if(fault==='overlapping source')s.shares[1].warehousePalletIds=['1'] - if(fault==='different destination')l.pallets[2].deliveryIndex=2 - if(fault==='wrong customer')l.cargo[0].customerId='FOREIGN' - if(fault==='wrong dispatch')l.cargo[0].dispatchNo='DS-FOREIGN' - if(fault==='wrong receipt')b.receipt.source.pallets[0].packages=2 - if(fault==='unbound booking')b.status='CONFIRMED' - if(fault==='uncovered external')s.departure.snapshot.externalBookings.push({...b,bookingNo:'BK-OTHER'}) - if(fault==='stale version')s.summary.departureVersion='8' - if(fault==='stale hash')s.summary.manifestHash='new-hash' - if(fault==='invented method')s.summary.allocationMethod='GUESS' - if(fault==='mixed currency')t.currency='CNY' - if(fault==='lost penny')t.allocations[0].amount='0.34' - if(fault==='wrong source type')t.allocations[2].sourceType='OWN' - if(fault==='wrong aggregate'){t.ownCost='0.67';t.externalCost='0.33'} - if(fault==='wrong residual winner'){t.allocations[0].amount='0.34';t.allocations[2].amount='0.33';t.ownCost='0.67';t.externalCost='0.33'} - assert.throws(()=>assertSameRouteCost(s.summary,s.departure,s.shares)) +test('three physical pallets share one native main and two business allocations with independent 67:33 oracle',()=>{ + const s=sample();assert.equal(assertSameRouteBasis(s.departure,s.shares).pallets.length,3) + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>[x.businessNo,x.cents]),[['BK-C','33'],['DS-OWN','67']]) + assert.equal(new Set(s.shares.map(x=>x.dispatchNo)).size,1) +}) +test('actual non-unit capacities aggregate own pallets; 4:2 remains 67:33',()=>{ + const s=sample([1,3],2);assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>x.cents),['33','67']) +}) +test('wire BigDecimal money numbers retain exact integer-cent verification',()=>{ + const s=sample(),t=s.summary.totals[0];t.directCost=1;t.ownCost=0.67;t.externalCost=0.33 + for(const row of t.allocations)row.amount=Number(row.amount) + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>x.cents),['33','67']) +}) +test('other actual weights use actual ratio rather than planned 2:1 or physical pallet count',()=>{ + const s=sample([1,2],2),t=s.summary.totals[0];t.ownCost='0.60';t.externalCost='0.40' + t.allocations.find(x=>x.sourceType==='OWN').amount='0.60';t.allocations.find(x=>x.sourceType==='EXTERNAL').amount='0.40' + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>x.cents),['40','60']) +}) +test('equal business capacities use business-number tie break for a residual cent',()=>{ + const s=sample([1,1],2),t=s.summary.totals[0];t.directCost='0.01';t.ownCost='0.00';t.externalCost='0.01' + t.allocations.find(x=>x.sourceType==='OWN').amount='0.00';t.allocations.find(x=>x.sourceType==='EXTERNAL').amount='0.01' + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares,'0.01').map(x=>x.cents),['1','0']) +}) +for(const fault of ['wrong action','missing pallet','overlapping source','different destination','wrong customer','wrong dispatch','wrong receipt','unbound booking','uncovered external','stale version','stale hash','invented method','mixed currency','lost penny','wrong source type','wrong aggregate','wrong residual winner','invented separate main','plan capacity','external plan capacity','unsafe quantity','missing own customer','foreign cargo'])test(`rejects invalid allocation evidence: ${fault}`,()=>{ + const s=sample(),l=s.departure.snapshot.confirmedLoad,t=s.summary.totals[0],b=s.departure.snapshot.externalBookings[0] + if(fault==='wrong action')s.departure.action='PLAN' + if(fault==='missing pallet')l.pallets.pop() + if(fault==='overlapping source')s.shares[0].warehousePalletIds[1]='3' + if(fault==='different destination')l.pallets[2].deliveryIndex=2 + if(fault==='wrong customer')l.cargo[0].customerId='FOREIGN' + if(fault==='wrong dispatch')l.cargo[0].dispatchNo='DS-FOREIGN' + if(fault==='wrong receipt')b.receipt.source.pallets[0].packages=2 + if(fault==='unbound booking')b.status='CONFIRMED' + if(fault==='uncovered external')s.departure.snapshot.externalBookings.push({...b,bookingNo:'BK-OTHER'}) + if(fault==='stale version')s.summary.departureVersion='9007199254740998' + if(fault==='stale hash')s.summary.manifestHash='new-hash' + if(fault==='invented method')s.summary.allocationMethod='GUESS' + if(fault==='mixed currency')t.currency='CNY' + if(fault==='lost penny')t.allocations[0].amount='0.66' + if(fault==='wrong source type')t.allocations[1].sourceType='OWN' + if(fault==='wrong aggregate'){t.ownCost='0.66';t.externalCost='0.34'} + if(fault==='wrong residual winner'){t.allocations[0].amount='0.66';t.allocations[1].amount='0.34';t.ownCost='0.66';t.externalCost='0.34'} + if(fault==='invented separate main')s.shares[1].dispatchNo='DS-OTHER' + if(fault==='plan capacity')s.shares[0].palletSpaces=1 + if(fault==='external plan capacity')b.receipt.pallets[0].palletSpaces=2 + if(fault==='unsafe quantity')l.cargo[0].packages=9007199254740992 + if(fault==='missing own customer')s.shares[0].customerIds=['101'] + if(fault==='foreign cargo')l.cargo.push({...l.cargo[0],warehousePalletId:'OTHER'}) + assert.throws(()=>assertSameRouteCost(s.summary,s.departure,s.shares)) +}) +function nativeSource(){ + const s=sample(),fixture={dispatchNo:'DS-OWN',members:[{palletId:'9007199254740993',customerId:'101',incomingNo:'IN-A'},{palletId:'2',customerId:'102',incomingNo:'IN-B'}]},receipt={warehousePalletId:'3',bookingNo:'BK-C',incomingNo:'IN-C',createRequest:{customerId:'101'}}, + bundle={detail:{dispatchNo:'DS-OWN',recordVersion:'0'},items:{dispatchNo:'DS-OWN',recordVersion:'0',items:s.departure.snapshot.confirmedLoad.cargo.map((c,i)=>({...c,inboundCode:['IN-A','IN-B','IN-C'][i],scheduledPackages:'1'}))}} + return {...s,fixture,receipt,bundle} +} +test('shares derive from native Detail/Items, real source identities and immutable measured load',()=>{ + const s=nativeSource();assert.deepEqual(sameRouteShares(s.departure,s.fixture,s.receipt,s.bundle),s.shares) +}) +for(const fault of ['version','source','customer','missing pallet'])test(`native source identity refuses ${fault}`,()=>{ + const s=nativeSource() + if(fault==='version')s.bundle.items.recordVersion='1' + if(fault==='source')s.bundle.items.items[2].inboundCode='HISTORY' + if(fault==='customer')s.bundle.items.items[1].customerId='101' + if(fault==='missing pallet')s.bundle.items.items.pop() + assert.throws(()=>sameRouteShares(s.departure,s.fixture,s.receipt,s.bundle)) +}) +function measurements(){ + const context={marker:'RUN'},trip={stops:[{name:'Origin'},{destinationKey:'FBA:A'}]},preview={dispatchNo:'DS-OWN',status:'ACTIVE',pallets:[{warehousePalletId:'9007199254740993',palletNo:'P1',scheduledPackages:'1',pickedPackages:'0',palletSpaces:null,deliveryIndex:1}]}, + row={warehousePalletId:'9007199254740993',palletSpaces:3,weightKg:'120.25',volumeCbm:'1.75',deliveryIndex:1} + row.evidence=['palletSpaces','weightKg','volumeCbm'].map(field=>({field,value:row[field],actual:true,sourceType:'WAREHOUSE_TRANSFER_PDA',sourceBusinessNo:'IN-A',observedAt:'2026-10-19T00:00:00Z'})) + const fixture={dispatchNo:'DS-OWN',members:[{palletId:'9007199254740993',incomingNo:'IN-A'}],loadingMeasurements:{environment:'TEST',marker:'RUN',dispatchNo:'DS-OWN',pallets:[row]}} + return {context,fixture,trip,preview} +} +test('pre-pick capacity reads physical evidence without falsifying picked counts or unit capacities',()=>{ + const s=measurements(),r=sameRouteMeasurements(s.context,s.fixture,s.trip,s.preview) + assert.equal(r.pallets[0].palletSpaces,3);assert.equal(s.preview.pallets[0].pickedPackages,'0') +}) +for(const fault of ['absent','marker','environment','dispatch','actual','field','duplicate','foreign','unit','conflict','stop','historical source'])test(`missing or foreign actual physical evidence blocks: ${fault}`,()=>{ + const s=measurements(),m=s.fixture.loadingMeasurements,row=m.pallets[0] + if(fault==='absent')delete s.fixture.loadingMeasurements + if(fault==='marker')m.marker='HISTORY' + if(fault==='environment')m.environment='DEV' + if(fault==='dispatch')m.dispatchNo='DS-OTHER' + if(fault==='actual')row.evidence[0].actual=false + if(fault==='field')delete row.palletSpaces + if(fault==='duplicate')m.pallets.push(structuredClone(row)) + if(fault==='foreign')m.pallets.push({...structuredClone(row),warehousePalletId:'OTHER'}) + if(fault==='unit')row.palletSpaces='3' + if(fault==='conflict'){s.preview.pallets[0].palletSpaces=2;s.preview.pallets[0].evidence=[{...row.evidence[0],value:2}]} + if(fault==='stop')row.deliveryIndex=0 + if(fault==='historical source')row.evidence[0].sourceBusinessNo='HISTORY' + assert.throws(()=>sameRouteMeasurements(s.context,s.fixture,s.trip,s.preview)) +}) +test('external actual capacity and dimensions come from operator-confirmed receipt',()=>{ + const s=measurements();s.trip.externalReceipt={status:'VERIFIED',finalBooking:{cargo:{destinationKey:'FBA:A'},receipt:{operatorId:'9007199254740998',confirmedAt:'2026-10-19T01:00:00Z', + source:{incomingNo:'IN-EXT',pallets:[{warehousePalletId:'3',weightKg:'110',volumeCbm:'1.1'}]},pallets:[{warehousePalletId:'3',palletSpaces:2}]}}} + const r=sameRouteMeasurements(s.context,s.fixture,s.trip,s.preview,{external:true}) + assert.equal(r.pallets[1].palletSpaces,2);assert.equal(r.pallets[1].weightKg,'110');assert.equal(r.pallets[1].evidence[0].sourceType,'FLEET_RECEIPT_CONFIRMATION') + delete s.trip.externalReceipt.finalBooking.receipt.operatorId + assert.throws(()=>sameRouteMeasurements(s.context,s.fixture,s.trip,s.preview,{external:true}),/EXTERNAL_MEASUREMENT/) +}) +test('executable native chain fails and checkpoints before any write when actual measurements are absent',async()=>{ + const physical=measurements().preview + physical.pallets.push({...physical.pallets[0],warehousePalletId:'2',palletNo:'P2'}) + const trip={dispatchNo:'DS-OWN',tripNo:'FT-OWN'},members=['101','102'].map((customerId,index)=>({marker:'RUN',customerId,dispatchNo:'DS-OWN',receiptVerified:true,incomingNo:['IN-A','IN-B'][index],palletId:['9007199254740993','2'][index],palletNo:['P1','P2'][index]})), + fixture={marker:'RUN',stage:fixtureContract.stage,dispatchNo:'DS-OWN',tripNo:'FT-OWN',warehouseId:'1',pdaToken:'EPHEMERAL',tripProof:trip,members}, + calls=[],context={marker:'RUN',createdResources:{fleetTrips:[trip]},assert(){},checkpoint:async()=>calls.push('checkpoint'),request:async(method,path)=>{ + calls.push(method) + const data=path.endsWith('/loading')?physical:path.endsWith('/capacity-plan')?{recordVersion:'0',manifestHash:'original',cargo:members.map(()=>({dispatchNo:'DS-OWN',destinationKey:'FBA:A'}))} + :{dispatches:[{dispatchNo:'DS-OWN'}],trip:{stops:[{name:'origin'},{destinationKey:'FBA:A'}]}} + return {http:200,body:{code:'200',data},requestId:'R',traceId:'T'} + }} + await assert.rejects(verifySameRouteCost(context,fixture,{execute:true,readInventory:async()=>{throw Error('should not read before measurements')}}),/ACTUAL_LOADING_MEASUREMENT_REQUIRED/) + assert.equal(context.createdResources.fleetSameRouteCost.status,'FAILED') + assert.ok(calls.includes('checkpoint'));assert.ok(!calls.includes('POST'));assert.ok(!calls.includes('PUT')) + assert.ok(!JSON.stringify(context.createdResources.fleetSameRouteCost).includes('EPHEMERAL')) +}) +function transport(response){ + const events=[],proof={requests:[]},context={createdResources:{},token:'NEVER_PERSIST_ME',assert(){},checkpoint:async()=>events.push(structuredClone(proof)), + request:async(...args)=>{events.push(args);if(response instanceof Error)throw response;return response}} + return {events,proof,context,io:sameRouteIo(context,proof)} +} +test('write intent is persisted before transport and zero / large-string facts remain lossless',async()=>{ + const s=transport({http:200,body:{code:'200',data:{recordVersion:'0',id:'9007199254740997'}},requestId:'R',traceId:'T'}),body={recordVersion:'9007199254740996',idempotencyKey:'ORIGINAL'} + assert.equal((await s.io.request('POST','/native',body)).recordVersion,'0') + assert.deepEqual(s.events[0].requests[0],{method:'POST',path:'/native',body,status:'SENDING'}) + assert.equal(s.proof.requests[0].result.id,'9007199254740997');assert.ok(!JSON.stringify(s.proof).includes('NEVER_PERSIST_ME')) +}) +for(const response of [new Error('network'),{http:503,body:{code:'ERROR'}},{http:200,body:{code:'200',data:null}},{http:200,body:{}}])test(`unknown write stops all later writes and retains original command: ${response instanceof Error?'transport':JSON.stringify(response)}`,async()=>{ + const s=transport(response),body={recordVersion:'0',idempotencyKey:'ORIGINAL'} + await assert.rejects(s.io.request('POST','/native',body));assert.equal(s.proof.requests[0].status,'UNKNOWN_RESULT');assert.equal(s.context.createdResources.dispatchWriteBoundary.stopped,true) + assert.deepEqual(s.proof.requests[0].body,body) + await assert.rejects(s.io.request('POST','/native',{...body,idempotencyKey:'REPLACEMENT'}),/STOPPED/);assert.equal(s.proof.requests.length,1) +}) +test('failed intent checkpoint prevents transport; failed result archive stops new writes',async()=>{ + const s=transport({http:200,body:{code:'200',data:{ok:true}},requestId:'R',traceId:'T'}) + s.context.checkpoint=async()=>{throw Error('disk')} + await assert.rejects(s.io.request('POST','/native',{idempotencyKey:'ORIGINAL'}),/disk/);assert.equal(s.events.length,0);assert.equal(s.proof.requests[0].status,'NOT_SENT') + const t=transport({http:200,body:{code:'200',data:{ok:true}},requestId:'R',traceId:'T'});let writes=0 + t.context.checkpoint=async()=>{if(++writes===2)throw Error('archive')} + await assert.rejects(t.io.request('POST','/native',{idempotencyKey:'ORIGINAL'}),/archive/) + await assert.rejects(t.io.request('POST','/native',{}),/STOPPED/) }) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 79b8b54ff..ab1c41f77 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -6,7 +6,8 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' import { redactEvidence } from './fba-core-regression-test-env.mjs' import { createTestEvidenceReaders, buildEvidenceRemoteScript, validateEvidenceConfig } from './fba-core-regression-evidence.mjs' -import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { prepareFleetTrip, finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { createBody, candidatePage, markerCandidates, dispatchIo, readDispatch, version as dispatchVersion, replay as replayDispatch } from './admin-test-env-modules/grouped-dispatch-acceptance.mjs' import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs' import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' @@ -14,8 +15,8 @@ import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-exte import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs' import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' import { verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit, selectReturnSources } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs' -import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' -import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { resolvePickerAccess, createPicker, disablePicker, pickerFixtureSuffix } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const MODULE = 'fleet-specialized-acceptance' @@ -23,10 +24,55 @@ const OPTIONS = { scenario: MODULE, deploymentBlocker: true } const id = value => value == null ? '' : String(value) const success = response => response?.http === 200 && id(response?.body?.code) === '200' const key = () => `fleet-${randomUUID()}` +const NATIVE_PROTOCOL = 'NATIVE_DISPATCH_V1' +const nativeDeparture = (value, dispatchNo) => value?.action === 'DEPART' && value.dispatchNo === dispatchNo + && value.status === 'ACTIVE' && value.stage === 'DELIVERING' && Number.isFinite(Date.parse(value.result?.departedAt)) + && Number.isSafeInteger(Number(value.result.shippedPackages)) && Number(value.result.shippedPackages) > 0 +const instant = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(value) ? `${value}Z` : value +const sameInstant = (a, b) => Number.isFinite(Date.parse(instant(a))) && Date.parse(instant(a)) === Date.parse(instant(b)) +const deliveryFacts = rows => JSON.stringify(rows.map(row => [id(row.customerId), row.deliveryNo, row.outboundNo, Number(row.packageCount)]).sort()) + +export function nativeFleetDeliveries(detail) { + if (!detail?.dispatchNo || !Array.isArray(detail.documents?.deliveries) || !detail.documents.deliveries.length) throw Error('Native delivery references missing') + const rows = new Map(), owners = new Map() + for (const row of detail.documents.deliveries) { + if (!row.deliveryNo || !row.outboundNo || !id(row.customerId) || !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) <= 0) + throw Error('Native delivery customer, business identity or quantity missing') + if (owners.has(row.deliveryNo) && owners.get(row.deliveryNo) !== id(row.customerId)) throw Error('Delivery cannot belong to two customers') + const identity = `${row.customerId}:${row.deliveryNo}`, prior = rows.get(identity) + if (prior && (prior.outboundNo !== row.outboundNo || Number(prior.packageCount) !== Number(row.packageCount) || prior.groupKey !== row.groupKey)) + throw Error('Duplicate native delivery references disagree') + owners.set(row.deliveryNo, id(row.customerId)); rows.set(identity, row) + } + return [...rows.values()] +} + +export function selectNativeFleetFullSource(context, original, candidates) { + const source = context.createdResources?.phase4OverseasDispatch + if (source?.marker !== context.marker || source.status !== 'VERIFIED' || original.data.dispatchNo !== source.dispatchNo + || original.data.status !== 'ACTIVE' || original.data.stage !== 'BEFORE_LOADING' || Number(original.data.counts.pickedPackageCount) !== 0 + || Number(original.data.counts.shippedPackageCount) !== 0 || id(original.data.recordVersion) !== id(source.recordVersion) + || original.items.items.length < 2 || new Set(original.items.items.map(i => i.destination?.key)).size !== 2) + throw Error('FULL/PARTIAL require the unchanged current-run two-destination native main') + const history = source.routeLifecycle?.stages?.find(s => s.label === '新增同目的地第二托')?.snapshot?.items + if (!history?.length) throw Error('Current-run released duplicate source evidence missing') + const own = original.items.items + const eligible = markerCandidates(context, candidates).filter(c => id(c.customerId) === id(source.customerId) + && id(c.warehouseId) === id(original.warehouseId) && history.some(i => i.sourceKey === c.sourceKey + && id(i.warehousePalletId) === id(c.selection.warehousePalletId)) + && own.every(i => i.sourceKey !== c.sourceKey && id(i.warehousePalletId) !== id(c.selection.warehousePalletId))) + if (eligible.length !== 1) throw Error('Need exactly one current-run released, free, non-overlapping FULL pallet; never borrow historical inventory') + return eligible[0] +} /** Install before login/prerequisites. A caught unknown write must not permit another request. */ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = {}) { let stopped = null, sequence = 0 + const stop = entry => { + context.createdResources ??= {} + context.createdResources.fleetWriteBoundary = { status: 'STOPPED', method: entry.method, path: entry.path, + outcome: entry.status, requestId: entry.requestId, traceId: entry.traceId } + } const scope = new AsyncLocalStorage(), originalFetch = globalThis.fetch const direct = { async requestWithBody(method, path, body, token, headers, request) { const nativeResponse = await originalFetch(request), copy = nativeResponse.clone() @@ -62,7 +108,7 @@ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = { ? { field, value } : { field, name: value.name, type: value.type, base64: Buffer.from(await value.arrayBuffer()).toString('base64') }) } - try { await checkpoint(entry) } catch (error) { stopped = error; throw error } + try { await checkpoint(entry) } catch (error) { entry.status = 'NOT_SENT'; stopped = error; stop(entry); throw error } try { const response = await scope.run(true, () => original.apply(this, arguments)) const http = response?.http, payload = response?.body, code = id(payload?.code) @@ -79,7 +125,7 @@ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = { responseBody: redactFleetWrite(response?.body), finishedAt: new Date().toISOString() }) if (binary) entry.binary = { contentType: response.contentType, bytes: response.bytes.length, sha256: createHash('sha256').update(response.bytes).digest('hex'), validPdf: Boolean(pdf) } - if (!definite) stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`) + if (!definite) { stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`); stop(entry) } await checkpoint(entry) if (stopped) throw stopped return response @@ -88,8 +134,9 @@ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = { if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; entry.finishedAt = new Date().toISOString() entry.raw = boundedFleetDiagnostic(error instanceof Error ? error.message : String(error)) - await checkpoint(entry) + stop(entry); await checkpoint(entry) } + stop(entry) throw stopped } } @@ -171,7 +218,7 @@ export function fleetExecutionPlan(config) { outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites', resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null, stages: config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' - ? ['Verify exact source-selection-failure report SHA256 and three definitive delivery starts', 'Read-only original DEPART/handover/cost/cargo/resources and DELIVERING versions', + ? ['Verify exact source-selection-failure report SHA256 and three definitive native delivery reads', 'Read-only original DEPART/handover/cost/cargo/resources and DELIVERING versions', 'Recheck repaired A/B return candidates and complete source quantities before any write', 'Create fresh bound PDA session', 'Original sample actual return and redispatch; no repeated original departure/handover/cost/start-delivery', 'Wait for coordinator pre-signature A0/B1 event evidence gate', 'New signature and source ACK evidence; close new and original resources'] @@ -184,9 +231,9 @@ export function fleetExecutionPlan(config) { afterCosts ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability', 'Retain original DEPART; no picking or departure commands', 'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions'] - : ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', - 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', - 'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint', + : ['two disjoint current-run native sources', 'independent FULL loading/coverage/departure/POD/resource closure', 'PARTIAL original main and reservation lifecycle', + 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'one native main PDA picking and explicit actual loading', + 'native current-version whole-main departure and cross-end exact replay', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint', 'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'], boundaries: 'TEST only; serial fresh fixtures; no SQL writes, deployment or configuration changes. Failure retains original requests/resources. No automatic rerun/resume.', limits: 'Finance pool proves consumer persistence; producer ACK requires separate event evidence. UI and remaining fleet product scope are separate.' } @@ -195,28 +242,33 @@ export function fleetExecutionPlan(config) { export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) + if (proof?.protocol !== NATIVE_PROTOCOL || !proof.dispatchNo || !trip || trip.dispatchNo !== proof.dispatchNo + || JSON.stringify(proof.dispatchNos) !== JSON.stringify([proof.dispatchNo]) || resources.dispatchWriteBoundary?.outcomeUnknown + || resources.fleetWriteBoundary?.status === 'STOPPED' + || (proof.departure && !nativeDeparture(proof.departure, proof.dispatchNo))) + throw Error('Only current native single-dispatch reports may resume; legacy or unknown outcomes require explicit reconciliation') if (stage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') { const attempt = trip?.returnRedispatch, originals = attempt?.originalDeliveries, writes = attempt?.requests?.filter(row => row.method !== 'GET') const failures = report?.steps?.filter(row => row.passed === false) if (report?.success !== false || proof?.stage !== 'RETURN_REDISPATCH' || proof.status !== 'CHECKING' || proof.marker !== attempt?.marker || trip?.costAcceptance?.status !== 'VERIFIED' || trip.handover?.status !== 'VERIFIED' - || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip.tripNo + || !nativeDeparture(proof.departure, proof.dispatchNo) || trip.closure || proof.closure || proof.receipts?.length || attempt.status !== 'FAILED' || attempt.failure !== 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' || attempt.exceptionNo || attempt.sources || attempt.newTripNo || attempt.originalSignatures - || originals?.length !== 3 || new Set(originals.map(row => row.deliveryNo)).size !== 3 || writes?.length !== 3 + || originals?.length !== 3 || new Set(originals.map(row => row.deliveryNo)).size !== 3 || writes?.length !== 0 || originals.some(row => id(row.customerId) !== id(proof.customerId) || id(row.overseasWarehouseId) !== id(proof.warehouseId) - || !proof.dispatchNos.includes(row.dispatchNo) || row.status !== 'WAIT_DELIVERY' || row.signedAt || Number(row.signedPackages || 0)) + || row.dispatchNo !== proof.dispatchNo || row.status !== 'DELIVERING' || row.signedAt || Number(row.signedPackages || 0)) || attempt.requests.some(row => row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200' || !row.requestId || !row.traceId) - || writes.some(row => { - const original = originals.find(item => item.deliveryNo === row.result?.deliveryNo) - return !original || row.method !== 'PUT' || row.path !== `/api/platform/v1/last-leg/delivery-orders/${original.deliveryNo}/status?customerId=${proof.customerId}` - || row.body?.status !== 'DELIVERING' || id(row.body.expectedVersion) !== id(original.version) - || row.result.deliveryStatus !== 'DELIVERING' || row.result.replayed !== false - }) || new Set(writes.map(row => row.result?.deliveryNo)).size !== 3 + || attempt.requests.length !== 3 || attempt.requests.some(row => { + const original = originals.find(item => item.deliveryNo === row.result?.order?.deliveryNo) + return row.method !== 'GET' || Object.hasOwn(row.result || {}, 'replayed') || !original + || row.path !== `/api/platform/v1/last-leg/delivery-orders/${original.deliveryNo}?customerId=${proof.customerId}` + || JSON.stringify(row.result.order) !== JSON.stringify(original) + }) || new Set(attempt.requests.map(row => row.result?.order?.deliveryNo)).size !== 3 || resources.fleetCostActors?.status !== 'DISABLED' || failures?.length !== 1 || failures[0].name !== '模块执行' || failures[0].evidence !== attempt.failure) - throw Error('Return source recovery requires the exact known candidate failure and three definitive delivery starts, with no return/signature writes') + throw Error('Return source recovery requires the exact native candidate failure, three already-delivering documents and no return/signature writes') return { resources, marker: proof.marker, returnSourceSelection: true } } if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' || stage === 'DEPARTED_VEHICLE_CREATED') { @@ -234,7 +286,7 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { || creation[0].result.status !== 'IN_SERVICE' || handover.replacement?.driverId)) throw Error('Created vehicle recovery requires the exact definitive successful creation and no later handover write') if (report?.success !== false || proof?.stage !== (afterCosts ? 'IN_TRANSIT_HANDOVER' : 'TRIP_OPERATING_COSTS') || proof.status !== 'CHECKING' - || proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo + || proof.marker !== costs?.marker || !nativeDeparture(proof.departure, proof.dispatchNo) || !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash || costs.status !== (afterCosts ? 'VERIFIED' : 'FAILED') || !Array.isArray(costs.costs) || costs.costs.length !== (afterCosts ? 2 : 0) || !costs.requests?.length || costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId @@ -255,7 +307,7 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE' || trip?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' || proof.closure?.status !== trip.closure.status || trip.externalReceipt?.status !== 'VERIFIED' - || trip.externalDispatch?.status !== 'VERIFIED' || trip.reassignment?.status !== 'VERIFIED' + || trip.externalDispatch?.status !== 'VERIFIED' || trip.externalDispatch.dispatchNo !== proof.dispatchNo || trip.reassignment?.status !== 'REJECTED_WITHOUT_MUTATION' || !proof.receipts?.length || proof.finance?.status !== 'CONSUMER_PERSISTED' || !failures?.length || failures.some(row => !['本次车次货物结案及车辆司机释放完成', '模块执行'].includes(row.name))) throw Error('Terminal reconciliation requires completed original business proofs and only the known aggregate assertion failure') @@ -265,7 +317,7 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { const confirmed = stage === 'EXTERNAL_RECEIPT_CONFIRMED' const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL'), receipt = trip?.externalReceipt if (report?.success !== false || proof?.stage !== 'EXTERNAL_RECEIPT' || proof.status !== 'CHECKING' - || full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo + || full?.proof?.status !== 'VERIFIED' || proof.fullExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' || partial?.proof || partial?.dispatchNo !== trip?.dispatchNo || trip.reservation?.status !== 'VERIFIED' || receipt?.status !== 'CHECKING' || trip.externalDispatch || (confirmed ? (!receipt.receiptRequest?.idempotencyKey || !receipt.requests?.some(row => row.label === '运营按实际1托位和新单价30确认实收' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) : receipt.receiptRequest) || !receipt.palletizeRequest?.idempotencyKey || !receipt.warehousePalletId || !proof.picker?.membershipId || !proof.pickerMarker @@ -276,20 +328,29 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { if (stage === 'EXTERNAL_RECEIPT_READY') { const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL') if (report?.success !== false || proof?.stage !== 'RESERVATIONS' || proof.status !== 'CHECKING' - || full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo + || full?.proof?.status !== 'VERIFIED' || proof.fullExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' || partial?.proof || partial?.dispatchNo !== trip?.dispatchNo || trip.reservation?.status !== 'VERIFIED' || trip.externalReceipt || trip.externalDispatch || proof.picker || !report.steps?.some(row => row.name === '创建海外PDA仓库操作员71' && row.code === 'A0409' && row.message === '手机号已存在')) throw Error('Receipt-ready checkpoint requires verified coverage/reservations and an explicitly rejected operator creation with no receipt writes') return { resources, marker: proof.marker, receiptReady: true } } if (report?.success !== false || proof?.stage !== 'FULL_COVERAGE' || proof.status !== 'CHECKING' || full?.mode !== 'FULL' - || full.marker !== proof.marker || full.dispatchNo !== trip?.dispatchNo || full.proof?.status !== 'CHECKING' + || full.marker !== proof.marker || full.dispatchNo === trip?.dispatchNo || !resources.fleetTrips.some(t => t.dispatchNo === full.dispatchNo && t.tripNo === proof.fullTripNo) + || !['CHECKING','UNPROVEN'].includes(full.proof?.status) || proof.fullExecution?.departureRequest || !full.proof.requests?.length - || resources.fleetFeeCoverageCases.length !== 1 || !proof.originalDispatch || proof.originalDispatch.dispatchNo !== full.dispatchNo) + || full.proof.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId) + || resources.fleetFeeCoverageCases.length !== 2 || !proof.originalDispatch?.detail || proof.originalDispatch.detail.dispatchNo !== proof.dispatchNo) throw Error('Only a reviewed read-only FULL_COVERAGE failure can resume; mutated/unknown outcomes require case-specific reconciliation') const persisted = stage === 'FULL_COVERAGE_PERSISTED' if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !full.proof.confirmationRequest?.idempotencyKey - || !full.proof.requests.some(row => row.label === '正式保存本次原费用服务覆盖并转派送' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) + || !full.proof.confirmationRequest.groups?.length || full.proof.confirmationRequest.recordVersion == null + || !full.proof.requests.some(row => row.method === 'POST' && row.path === `/api/platform/v1/last-leg/dispatches/${full.dispatchNo}/transfer-delivery` + && row.http === 200 && String(row.code) === '200' && row.status === 'RESPONDED' + && JSON.stringify(row.body) === JSON.stringify(full.proof.confirmationRequest) + && row.result?.action === 'ARRANGE_DELIVERY' && row.result.dispatchNo === full.dispatchNo + && row.result.status === 'ACTIVE' && row.result.stage === 'AFTER_LOADING' + && row.result.result?.outbounds?.some(o => o.outboundNo === full.proof.outboundNo) + && JSON.stringify(row.result.result?.deliveries?.map(d => d.deliveryNo).sort()) === JSON.stringify([...full.proof.deliveryNos].sort()))) : (full.proof.requests.some(row => row.method !== 'GET') || full.proof.outboundNo)) throw Error('Mutated/unknown outcomes require the exact successful command and persisted document reconciliation') return { resources, marker: proof.marker, persisted } @@ -298,58 +359,36 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { export async function verifyFleetTerminalState(context) { const proof = context.createdResources.fleetAcceptance const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) - const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') const evidence = proof.terminalReadback = { status: 'CHECKING', requests: [], deliveries: [], dispatches: [] } - function check(label, passed, response) { - context.assert(MODULE, label, response, Boolean(passed), '', OPTIONS) - if (!passed) throw Error(label) - } - async function get(path) { - const result = await context.request('GET', path) - evidence.requests.push({ path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId }) - check('终态只读回查成功', success(result) && result.requestId && result.traceId, result) - return result.body.data - } - check('结案助手已完成货物与资源核对', trip.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') - const current = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`) - check('当前车次已完成且两项资源结束事实与原证明一致', current.trip?.status === 'COMPLETED' - && current.trip.completedAt === trip.closure.finalTrip.completedAt - && id(current.trip.recordVersion) === id(trip.closure.finalTrip.recordVersion) - && JSON.stringify(current.trip.vehicleRelease) === JSON.stringify(trip.closure.finalTrip.vehicleRelease) - && JSON.stringify(current.trip.driverRelease) === JSON.stringify(trip.closure.finalTrip.driverRelease) - && current.trip.vehicleRelease && current.trip.driverRelease) - evidence.trip = current.trip - const expectedDispatches = [partial.dispatchNo, trip.externalDispatch.dispatchNo].sort() - check('终态车次仍关联全部原执行单', JSON.stringify(current.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify(expectedDispatches)) - for (const number of expectedDispatches) { - const dispatch = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`) - check('原执行单已发车且拣货实发数量守恒', dispatch.dispatchNo === number && dispatch.status === 'DEPARTED' - && Number(dispatch.packageCount) === Number(dispatch.shippedPackageCount) - && Number(dispatch.pickedPackageCount) === Number(dispatch.shippedPackageCount)) - evidence.dispatches.push(dispatch) - } - check('整车最终箱数与原一次发车事实一致', evidence.dispatches.reduce((n, row) => n + Number(row.shippedPackageCount), 0) === Number(proof.departure.shippedPackages)) - const expectedDeliveries = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo].sort() - check('签收证明覆盖全部目的地派送', JSON.stringify(proof.receipts.map(row => row.deliveryNo).sort()) === JSON.stringify(expectedDeliveries)) - for (const number of expectedDeliveries) { - const delivery = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) - check('每张原派送保留完成及签收时间', delivery.order?.deliveryNo === number && delivery.order.status === 'DELIVERED' - && delivery.order.signedAt === proof.receipts.find(row => row.deliveryNo === number).signedAt) - evidence.deliveries.push(delivery.order) - } - for (const probe of trip.closure.probes) { - const detail = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(probe.tripNo)}`) - check('资源占用测试计划均已正式取消', detail.trip?.status === 'CANCELLED') - } - const pool = await get(`/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(proof.customerId)}&direction=RECEIVABLE`) - const fees = pool.filter(row => id(row.sourceId) === id(proof.finance.source.sourceFeeId) && Number(row.sourceVersion) === Number(proof.finance.source.sourceVersion)) - check('原外接应收仍唯一且金额正确', fees.length === 1 && fees[0].amount === 30 && fees[0].currency === 'CNY' - && id(fees[0].customerId) === id(proof.customerId) && fees[0].sourceType === 'OVERSEAS_DELIVERY_FEE') - evidence.fees = fees; evidence.status = 'VERIFIED' - proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; proof.stage = 'FINISHED' - return proof + const check = (label,passed) => { context.assert(MODULE,label,null,Boolean(passed),'',OPTIONS);if(!passed)throw Error(label) } + async function get(path) { const r=await context.request('GET',path);evidence.requests.push({path,http:r.http,code:r.body?.code,requestId:r.requestId,traceId:r.traceId});check('终态只读回查成功',success(r)&&r.requestId&&r.traceId);return r.body.data } + check('原生结案助手已完成货物与资源核对', proof.protocol===NATIVE_PROTOCOL && trip?.dispatchNo===proof.dispatchNo && trip.closure?.status==='CARGO_COMPLETED_AND_RESOURCES_RELEASED') + const current=await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`) + check('当前车次已完成且两项资源结束事实与原证明一致',current.trip?.status==='COMPLETED' + && current.trip.completedAt===trip.closure.finalTrip.completedAt && id(current.trip.recordVersion)===id(trip.closure.finalTrip.recordVersion) + && JSON.stringify(current.trip.vehicleRelease)===JSON.stringify(trip.closure.finalTrip.vehicleRelease) + && JSON.stringify(current.trip.driverRelease)===JSON.stringify(trip.closure.finalTrip.driverRelease) + && current.trip.vehicleRelease&¤t.trip.driverRelease&¤t.dispatches?.length===1&¤t.dispatches[0].dispatchNo===proof.dispatchNo) + evidence.trip=current.trip + const detail=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`) + check('原生主单全部正常派送完成且数量守恒',detail.dispatchNo===proof.dispatchNo&&detail.status==='ACTIVE'&&detail.stage==='DELIVERED' + && Number(detail.counts?.packageCount)===Number(detail.counts?.shippedPackageCount)&&Number(detail.counts?.pickedPackageCount)===Number(detail.counts?.shippedPackageCount) + && Number(detail.counts?.shippedPackageCount)===Number(proof.departure.result.shippedPackages)) + evidence.dispatches.push(detail) + const refs=nativeFleetDeliveries(detail) + check('签收证明恰好覆盖主单每客户派送一次',JSON.stringify(proof.receipts.map(r=>`${r.customerId}:${r.deliveryNo}`).sort())===JSON.stringify(refs.map(r=>`${r.customerId}:${r.deliveryNo}`).sort())) + for(const ref of refs){const delivery=await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`) + check('每张原派送保留客户和真实签收时间',delivery.order?.deliveryNo===ref.deliveryNo&&id(delivery.order.customerId)===id(ref.customerId)&&delivery.order.status==='DELIVERED' + && delivery.order.signedAt===proof.receipts.find(r=>r.deliveryNo===ref.deliveryNo&&r.customerId===id(ref.customerId)).signedAt);evidence.deliveries.push(delivery.order)} + const probe=trip.closure.nextDispatch + check('原生空排车占用保护证明完整',trip.closure.subsequentTripClaimProtection==='VERIFIED_NATIVE_EMPTY_DISPATCH'&&probe?.dispatchNo&&probe.finalStatus==='CANCELLED') + const cancelled=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(probe.dispatchNo)}`) + check('本轮资源核对原生空排车已取消',cancelled.dispatchNo===probe.dispatchNo&&cancelled.status==='CANCELLED'&&Number(cancelled.counts?.packageCount)===0&&Number(cancelled.counts?.pickedPackageCount)===0) + const customerId=id(proof.finance.customerId),pool=await get(`/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`) + const fees=pool.filter(row=>id(row.sourceId)===id(proof.finance.source.sourceFeeId)&&id(row.sourceVersion)===id(proof.finance.source.sourceVersion)) + check('原外接应收仍唯一且金额正确',fees.length===1&&Number(fees[0].amount)===30&&fees[0].currency==='CNY'&&id(fees[0].customerId)===customerId&&fees[0].sourceType==='OVERSEAS_DELIVERY_FEE') + evidence.fees=fees;evidence.status='VERIFIED';proof.status='HTTP_CHAIN_VERIFIED_WITH_ACK_GAP';proof.stage='FINISHED';return proof } - /** All business checks happen before creating replacement test sessions or issuing a business command. */ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle, returnSourceReady = false } = {}) { const proof = context.createdResources.fleetAcceptance @@ -369,7 +408,7 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f } const current = await get(base) check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(expectedTrip.recordVersion) - && current.trip.departedAt === proof.departure.departedAt && id(current.trip.warehouseId) === id(proof.warehouseId) + && sameInstant(current.trip.departedAt, proof.departure.result.departedAt) && id(current.trip.warehouseId) === id(proof.warehouseId) && id(current.trip.vehicleId) === id(expectedTrip.vehicleId) && id(current.trip.driverId) === id(expectedTrip.driverId) && !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed') const history = await get(`${base}/history?page=1&size=100`) @@ -405,34 +444,37 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f const expected = trip.handover.requests.filter(row => row.method === 'GET' && row.path.startsWith(`${base}/handovers?`)).at(-1)?.result check(expected && Number(handovers.total) === 1 && same(handovers.records, expected.records), 'original verified handover changed') } else check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists') - check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort()) - && current.dispatches.every(row => id(row.customerId) === id(proof.customerId)), 'trip dispatch binding changed') - for (const number of proof.dispatchNos) { - const detail = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`) - const cargo = load.cargo.filter(row => row.dispatchNo === number) - const facts = rows => rows.map(row => [id(row.id ?? row.dispatchItemId), id(row.warehousePalletId), Number(row.packageCount ?? row.packages)]) - .sort((a, b) => a[0].localeCompare(b[0])) - check(detail.dispatchNo === number && detail.status === 'DEPARTED' && id(detail.overseasWarehouseId) === id(proof.warehouseId) - && Number(detail.packageCount) === Number(detail.shippedPackageCount) && Number(detail.pickedPackageCount) === Number(detail.shippedPackageCount) - && same(facts(detail.items), facts(cargo)), 'dispatch shipped cargo changed') - evidence.dispatchDetails.push(detail) + check(proof.protocol === NATIVE_PROTOCOL && nativeDeparture(proof.departure,proof.dispatchNo) + && trip.dispatchNo === proof.dispatchNo && current.dispatches?.length === 1 && current.dispatches[0].dispatchNo === proof.dispatchNo + && JSON.stringify(proof.dispatchNos) === JSON.stringify([proof.dispatchNo]), 'trip dispatch binding changed') + const detail=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`) + const items=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}/items`) + const cargo=load.cargo + const facts=rows=>{ + const totals=new Map() + for(const row of rows){const identity=JSON.stringify([id(row.dispatchItemId),id(row.warehousePalletId),id(row.customerId)]);totals.set(identity,(totals.get(identity)||0)+Number(row.scheduledPackages??row.packages))} + return [...totals].sort(([a],[b])=>a.localeCompare(b)) } - const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') - const deliveryNos = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo] - check(new Set(deliveryNos).size === deliveryNos.length, 'duplicate delivery identities') - for (const number of deliveryNos) { - const detail = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) - const order = detail.order - const started = returnSourceReady ? trip.returnRedispatch.requests.find(row => row.method === 'PUT' && row.result?.deliveryNo === number) : null - check(order?.deliveryNo === number && id(order.customerId) === id(proof.customerId) && proof.dispatchNos.includes(order.dispatchNo) - && order.status === (returnSourceReady ? 'DELIVERING' : 'WAIT_DELIVERY') - && id(order.version) === (returnSourceReady ? String(Number(started?.body.expectedVersion) + 1) : '1') - && (!returnSourceReady || order.vehiclePlate === expectedTrip.vehiclePlate && order.driverName === expectedTrip.driverName && order.driverPhone === expectedTrip.driverPhone) - && !order.signedAt && !Number(order.signedPackages || 0) - && !order.completedAt && order.documentSummary?.categories?.every(row => Number(row.fileCount) === 0), 'delivery has started, changed or been signed') + check(detail.dispatchNo===proof.dispatchNo&&detail.status==='ACTIVE'&&detail.stage==='DELIVERING'&&items.dispatchNo===proof.dispatchNo + && id(items.recordVersion)===id(detail.recordVersion)&&Number(detail.counts?.packageCount)===Number(detail.counts?.shippedPackageCount) + && Number(detail.counts?.pickedPackageCount)===Number(detail.counts?.shippedPackageCount)&&cargo.every(row=>row.dispatchNo===proof.dispatchNo) + && same(facts(items.items),facts(cargo))&&proof.departureSnapshot?.detail?.dispatchNo===proof.dispatchNo + && same(items,proof.departureSnapshot.items),'dispatch shipped cargo changed') + evidence.dispatchDetails.push({detail,items}) + const refs=nativeFleetDeliveries(detail) + check(new Set(refs.map(d=>d.deliveryNo)).size===refs.length + && deliveryFacts(refs)===deliveryFacts(nativeFleetDeliveries(proof.departureSnapshot.detail)),'duplicate or changed delivery identities') + for(const ref of refs){ + const response=await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`),order=response.order + const expected=(returnSourceReady?trip.returnRedispatch.originalDeliveries:proof.departureDeliveries)?.find(d=>d.deliveryNo===ref.deliveryNo&&id(d.customerId)===id(ref.customerId)) + check(expected&&order?.deliveryNo===ref.deliveryNo&&id(order.customerId)===id(ref.customerId)&&order.dispatchNo===proof.dispatchNo + && order.outboundNo===ref.outboundNo&&order.status==='DELIVERING'&&id(order.version)===id(expected.version) + && order.outboundNo===expected.outboundNo&&Number(order.packageCount)===Number(ref.packageCount)&&Number(order.packageCount)===Number(expected.packageCount) + && (!returnSourceReady||order.vehiclePlate===expectedTrip.vehiclePlate&&order.driverName===expectedTrip.driverName&&order.driverPhone===expectedTrip.driverPhone) + && !order.signedAt&&!Number(order.signedPackages||0)&&!order.completedAt&&order.documentSummary?.categories?.every(r=>Number(r.fileCount)===0),'delivery has started, changed or been signed') evidence.deliveries.push(order) } - check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch') + check(evidence.deliveries.reduce((n,row)=>n+Number(row.packageCount),0)===Number(proof.departure.result.shippedPackages),'delivery quantity mismatch') if (createdVehicle) { const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${encodeURIComponent(createdVehicle.snapshot.id)}`) const canonical = value => value && typeof value === 'object' && !Array.isArray(value) @@ -524,8 +566,8 @@ export async function continueReviewedReturnSources(context, { verify = verifyRe run.picker = { membershipId: picker.membershipId, username: picker.username }; await context.checkpoint() const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') const result = await redispatch(context, MODULE, trip, { customerId: run.customerId, warehouseId: run.warehouseId, - pickerToken: picker.token, dispatchNos: run.dispatchNos, deliveryNos: [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo], sourceSelectionRecovery: true }) - run.receipts = result.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) + pickerToken: picker.token, dispatchNos: run.dispatchNos, deliveryNos: nativeFleetDeliveries(verified.dispatchDetails[0].detail).map(ref => ref.deliveryNo), sourceSelectionRecovery: true }) + run.receipts = result.originalSignatures.map(row => ({ customerId: id(row.customerId || run.customerId), deliveryNo: row.deliveryNo, signedAt: row.signedAt })) context.context.saveFleetReturnRedispatchFixture(result) run.closure = await closeOriginal(context, MODULE, trip) if (run.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('Recovered original trip did not close') @@ -538,8 +580,8 @@ export async function continueReviewedReturnSources(context, { verify = verifyRe export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady, reviewedVehicle } = {}) { const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails, { reviewedVehicle }) const run = context.createdResources.fleetAcceptance - if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo - || run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser + if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.tripNo !== trip.tripNo + || !nativeDeparture(run.departure, trip.dispatchNo) || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser || trip.costAcceptance?.status !== 'VERIFIED' || trip.costAcceptance.marker !== context.marker) throw Error('Only the current verified DEPART and handover can publish a shared fixture') const fixture = { marker: context.marker, runId: context.context?.fleetRunId, tripNo: trip.tripNo, warehouseId: trip.warehouseId, dispatchNos: state.dispatchNos, @@ -556,21 +598,26 @@ export async function runFleetHandoverStage(context, trip, pickerToken, dispatch export function fleetCostShares(trip, dispatchDetails) { const external = trip.externalReceipt, externalDispatch = trip.externalDispatch - if (external?.status !== 'VERIFIED' || !external.bookingNo || !external.warehousePalletId || !externalDispatch?.dispatchNo) + if (external?.status !== 'VERIFIED' || !external.bookingNo || !external.warehousePalletId + || !trip.dispatchNo || externalDispatch?.dispatchNo !== trip.dispatchNo) throw Error('Cost expectations require this run\'s verified external receipt') - const shares = dispatchDetails.map(row => { - const pallets = [...new Set((row.items || []).map(item => id(item.warehousePalletId)))] - if (!row.dispatchNo || !pallets.length || pallets.some(value => !value)) throw Error('Cost expectations require actual dispatch pallets') - const isExternal = row.dispatchNo === externalDispatch.dispatchNo - if (isExternal && (pallets.length !== 1 || pallets[0] !== id(external.warehousePalletId))) throw Error('External cost source does not match actual receipt') - // The fixture's explicit measurement rule is one space per physical pallet; never derive this from cost totals. - return { businessNo: isExternal ? external.bookingNo : row.dispatchNo, sourceType: isExternal ? 'EXTERNAL' : 'OWN', - dispatchNo: row.dispatchNo, warehousePalletIds: pallets, palletSpaces: pallets.length } - }) - const pallets = shares.flatMap(row => row.warehousePalletIds) - if (shares.filter(row => row.sourceType === 'EXTERNAL').length !== 1 || !shares.some(row => row.sourceType === 'OWN') - || new Set(pallets).size !== pallets.length) throw Error('Cost fixture requires distinct own and external physical pallets') - return shares + const bundle = dispatchDetails?.length === 1 ? dispatchDetails[0] : null + const detail = bundle?.detail, items = bundle?.items + if (detail?.dispatchNo !== trip.dispatchNo || items?.dispatchNo !== trip.dispatchNo + || id(detail.recordVersion) !== id(items.recordVersion) || !items.items?.length) + throw Error('Cost expectations require the same native dispatch Detail and Items version') + const pallets = [...new Set(items.items.map(item => id(item.warehousePalletId)))].sort() + const measured = trip.confirmedLoad?.pallets + if (pallets.some(value => !value) || !trip.confirmedLoad?.manifestHash || !Array.isArray(measured) + || measured.length !== pallets.length || new Set(measured.map(row => id(row.warehousePalletId))).size !== measured.length + || measured.some(row => !pallets.includes(id(row.warehousePalletId)) || !Number.isSafeInteger(row.palletSpaces) || row.palletSpaces < 1)) + throw Error('Cost expectations require exact actual loading measurements for every distinct pallet') + const externalId = id(external.warehousePalletId), own = pallets.filter(value => value !== externalId) + if (!pallets.includes(externalId) || !own.length) throw Error('External receipt and own cargo require distinct actual pallets') + const share = (sourceType, businessNo, ids) => ({ businessNo, sourceType, dispatchNo: trip.dispatchNo, + warehousePalletIds: ids, palletSpaces: measured.filter(row => ids.includes(id(row.warehousePalletId))) + .reduce((sum, row) => sum + row.palletSpaces, 0) }) + return [share('OWN', trip.dispatchNo, own), share('EXTERNAL', external.bookingNo, [externalId])] } export function fleetCostActorJournalData(value) { @@ -681,204 +728,259 @@ export async function findFleetActorPhone(context, actorSuffix) { throw Error('No available phone in the fresh actor scope; do not replay user creation') } +async function nativeRequest(context, method, path, body, token) { + const result = await context.request(method, path, body, token) + if (!success(result) || !result.requestId || !result.traceId) throw Error(`Native fleet request failed: ${method} ${path}`) + return result.body.data +} +export async function readNativeFleetBundle(context, dispatchNo) { + const base = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}` + const detail = await nativeRequest(context, 'GET', base) + const items = await nativeRequest(context, 'GET', `${base}/items`) + if (detail.dispatchNo !== dispatchNo || items.dispatchNo !== dispatchNo || id(dispatchVersion(detail.recordVersion)) !== id(dispatchVersion(items.recordVersion))) throw Error('Native Detail/Items version mismatch') + return { detail, items } +} + +// 只读取本轮生产者和运营已确认实测,不从计划托位、计费重量或常数生成实际量。 +export async function currentFleetMeasurements(context, trip) { + const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}/loading` + const preview = await nativeRequest(context, 'GET', path) + if (preview.dispatchNo !== trip.dispatchNo || preview.status !== 'ACTIVE' || !preview.pallets?.length) throw Error('Current native physical loading preview missing') + const producer = context.createdResources.phase4OverseasDispatch?.loadingMeasurements + if (producer && (producer.marker !== context.marker || producer.environment !== 'TEST' || producer.dispatchNo !== trip.dispatchNo || !Array.isArray(producer.pallets))) + throw Error('Physical measurements must be the exact current-run producer receipt for this dispatch') + const actual = (producer?.pallets || []).map(row => structuredClone(row)) + const receipt = trip.externalReceipt?.finalBooking?.receipt + for (const measured of receipt?.pallets || []) { + const source = receipt.source?.pallets?.find(p => id(p.warehousePalletId) === id(measured.warehousePalletId)) + if (!source || !Number.isFinite(Date.parse(receipt.confirmedAt)) || !receipt.operatorId || !receipt.source?.incomingNo) throw Error('External actual measurement ownership missing') + actual.push({ warehousePalletId: id(measured.warehousePalletId), palletSpaces: measured.palletSpaces, + weightKg: measured.weightKg ?? source.weightKg, volumeCbm: measured.volumeCbm ?? source.volumeCbm, + deliveryIndex: trip.stops.findIndex(s => s.destinationKey === trip.externalReceipt.finalBooking.cargo.destinationKey), + evidence: ['palletSpaces','weightKg','volumeCbm'].map(field => ({ field, value: field === 'palletSpaces' ? measured[field] : measured[field] ?? source[field], + actual: true, sourceType: 'FLEET_RECEIPT_CONFIRMATION', sourceBusinessNo: receipt.source.incomingNo, observedAt: receipt.confirmedAt })) }) + } + if (new Set(actual.map(p => id(p.warehousePalletId))).size !== actual.length) throw Error('Duplicate current-run physical measurement receipts') + for (const pallet of preview.pallets) { + const supplied = actual.find(p => id(p.warehousePalletId) === id(pallet.warehousePalletId)) + for (const field of ['palletSpaces','weightKg','volumeCbm']) { + const source = pallet[field] == null ? supplied : pallet, value = source?.[field] + if (!(Number(value) > 0) || !source?.evidence?.some(e => e.field === field && e.actual === true && e.sourceBusinessNo && e.sourceType + && Number.isFinite(Date.parse(e.observedAt)) && Number(e.value) === Number(value))) + throw Error(`Missing current-run actual ${field} for ${pallet.palletNo || 'unassociated pallet'}; no planning constants or external receipt injection`) + } + const index = pallet.deliveryIndex ?? supplied?.deliveryIndex + if (!Number.isSafeInteger(index) || index < 1 || !trip.stops?.[index]?.destinationKey) + throw Error('Current-run physical pallet has no verified unloading stop') + } + if (actual.some(p => !preview.pallets.some(row => id(row.warehousePalletId) === id(p.warehousePalletId)) + && !receipt?.source?.pallets?.some(row => id(row.warehousePalletId) === id(p.warehousePalletId)))) throw Error('Physical receipts contain a pallet outside this dispatch and its confirmed incoming') + return { environment: 'TEST', marker: context.marker, dispatchNo: trip.dispatchNo, pallets: actual } +} + +export async function departNativeFleet(context, trip, pickerToken, run) { + if (!pickerToken || !run || run.dispatchNo !== trip.dispatchNo || run.tripNo !== trip.tripNo || run.departureRequest) + throw Error('Native departure requires owned main and a new original command') + const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}` + const before = await readNativeFleetBundle(context, trip.dispatchNo) + const refs = nativeFleetDeliveries(before.detail) + const task = await nativeRequest(context, 'GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}`, undefined, pickerToken) + if (before.detail.status !== 'ACTIVE' || before.detail.stage !== 'AFTER_LOADING' || task.dispatchNo !== trip.dispatchNo || task.status !== 'ACTIVE' || task.stage !== 'AFTER_LOADING' + || id(task.warehouseId) !== id(trip.warehouseId) || id(task.recordVersion) !== id(before.detail.recordVersion) || task.actions?.DEPART?.allowed !== true + || !before.detail.pickingReconciled || !task.pickingReconciled || Number(before.detail.counts?.packageCount) !== Number(task.counts?.packageCount) + || Number(task.counts?.packageCount) !== Number(task.counts?.pickedPackageCount) || Number(task.counts?.shippedPackageCount) !== 0 + || before.items.items.some(row => Number(row.pickedPackages) !== Number(row.scheduledPackages)) + || before.items.items.reduce((n,row) => n + Number(row.scheduledPackages), 0) !== Number(task.counts?.packageCount) + || refs.reduce((n,d) => n + Number(d.packageCount), 0) !== Number(task.counts.packageCount)) throw Error('Native main is not fully loaded and arranged for both ends') + run.departureRequest = { recordVersion: task.recordVersion, idempotencyKey: key(), remark: 'TEST同一原生主单实际发车,不重复扣库存' } + await context.checkpoint?.() + const departed = await nativeRequest(context, 'POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}/depart`, run.departureRequest, pickerToken) + if (!nativeDeparture(departed, trip.dispatchNo) || BigInt(dispatchVersion(departed.recordVersion)) <= BigInt(dispatchVersion(task.recordVersion)) + || Number(departed.result.shippedPackages) !== Number(task.counts.packageCount) + || !Array.isArray(departed.result.deliveries) || deliveryFacts(departed.result.deliveries) !== deliveryFacts(refs)) + throw Error('Native departure did not preserve all customer shipment identities and quantities') + run.departure = departed + const replayed = await nativeRequest(context, 'POST', `${path}/depart`, run.departureRequest) + if (!nativeDeparture(replayed, trip.dispatchNo) || replayed.replayed !== true || id(replayed.recordVersion) !== id(departed.recordVersion) || JSON.stringify(replayed.result) !== JSON.stringify(departed.result)) + throw Error('Native cross-end departure replay changed the original outcome') + run.departureReplay = replayed + const after = await readNativeFleetBundle(context, trip.dispatchNo) + if (after.detail.status !== 'ACTIVE' || after.detail.stage !== 'DELIVERING' || id(after.detail.recordVersion) !== id(departed.recordVersion) + || Number(after.detail.counts.shippedPackageCount) !== Number(task.counts.packageCount) || Number(after.detail.counts.packageCount) !== Number(task.counts.packageCount) + || Number(after.detail.counts.pickedPackageCount) !== Number(after.detail.counts.shippedPackageCount) + || deliveryFacts(nativeFleetDeliveries(after.detail)) !== deliveryFacts(refs)) throw Error('Native departure readback did not conserve physical quantities') + run.departureSnapshot = after; run.dispatchNos = [trip.dispatchNo] + run.departureDeliveries = [] + for (const ref of nativeFleetDeliveries(after.detail)) { + const value = await nativeRequest(context, 'GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`) + if (value.order?.deliveryNo !== ref.deliveryNo || id(value.order.customerId) !== id(ref.customerId) || value.order.dispatchNo !== trip.dispatchNo + || value.order.status !== 'DELIVERING' || value.order.version == null || Number(value.order.packageCount) !== Number(ref.packageCount)) throw Error('Native departure delivery versions or owners changed') + run.departureDeliveries.push(value.order) + } + await context.checkpoint?.(); return { departed, dispatchDetails: [after] } +} + export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), onHandoverReady, costActors, resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) { if (terminalReadOnly) return verifyFleetTerminalState(context) if (costActors?.marker !== context.marker || !costActors.listOnlyToken || !costActors.deniedToken || costActors.listOnlyToken === costActors.deniedToken || [costActors.listOnlyToken, costActors.deniedToken].includes(context.token)) throw Error('Prepare two independent current-run platform cost permission sessions before starting the fleet scenario') - const proof = resumeFull || resumeReceiptReady ? context.createdResources.fleetAcceptance - : context.createdResources.fleetAcceptance = { marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] } + const resumed = resumeFull || resumeReceiptReady + const proof = resumed ? context.createdResources.fleetAcceptance : context.createdResources.fleetAcceptance = { + protocol: NATIVE_PROTOCOL, marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] } + if (proof.protocol !== NATIVE_PROTOCOL) throw Error('Legacy multi-dispatch reports cannot enter the native main chain') function stage(value) { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log(`FLEET_STAGE=${value}`) } - function check(label, value, response) { context.assert(MODULE, label, response, Boolean(value), '', OPTIONS); if (!value) throw Error(label) } - async function request(method, path, body, label, token) { - const response = await context.request(method, path, body, token) - check(label, success(response), response); return response.body.data - } const source = context.createdResources.phase4OverseasDispatch, receiving = context.createdResources.phase4OverseasPda - check('专项只承接本轮实际已收货及预排资源', source?.marker === context.marker && source.dispatchNo - && receiving?.marker === context.marker && receiving.finalStatus === 'COMPLETED' && receiving.customerId && receiving.overseasWarehouseId) + if (source?.marker !== context.marker || source.status !== 'VERIFIED' || !source.dispatchNo || receiving?.marker !== context.marker + || receiving.finalStatus !== 'COMPLETED' || !receiving.customerId || !receiving.overseasWarehouseId) throw Error('Current-run native dispatch and real received warehouse evidence required') const customerId = id(receiving.customerId), warehouseId = id(receiving.overseasWarehouseId) - proof.customerId = customerId; proof.warehouseId = warehouseId - const dispatchBase = '/api/platform/v1/last-leg/dispatches' - let trip, partial - if (resumeReceiptReady) { - trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) - partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') - const current = await request('GET', `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`, undefined, '恢复实收前核对原车次分配与客户执行单') - const dispatch = await request('GET', `${dispatchBase}/${encodeURIComponent(partial.dispatchNo)}`, undefined, '恢复实收前核对原排车尚未拣货或发车') - check('仍是原已核对车辆司机与未执行客户排车', current.trip?.status === 'PLANNED' - && id(current.trip.vehicleId) === id(trip.vehicleId) && id(current.trip.driverId) === id(trip.driverId) - && current.dispatches?.length === 1 && current.dispatches[0].dispatchNo === partial.dispatchNo - && id(current.dispatches[0].customerId) === customerId && dispatch.dispatchNo === partial.dispatchNo - && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + Object.assign(proof, { customerId, warehouseId, dispatchNo: source.dispatchNo, dispatchNos: [source.dispatchNo] }) + const journal = dispatchIo(context, MODULE, proof, 'FLEET-NATIVE') + let full, partial, trip + const template = { plannedDeliveryAt: new Date(Date.now()+21600000).toISOString(), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', + loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本轮实际装载', appointmentAccount: 'SELF', remark: `${context.marker} TEST原费用覆盖`, + fees: [{ feeType: 'DELIVERY_FEE', feeName: '未覆盖原入仓派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10 }] } + if (!resumed) { + const original = await readDispatch(journal, source.dispatchNo) + const candidate = selectNativeFleetFullSource(context, original, await candidatePage(journal, warehouseId)) + proof.originalDispatch = { detail: original.data, items: original.items, route: original.route } + proof.fullCreateRequest = createBody(context.marker, 'FLEET-FULL-INDEPENDENT', [candidate]) + const created = await journal.command('POST', '/api/platform/v1/last-leg/dispatches', proof.fullCreateRequest, '释放的本轮真实第三托创建独立FULL原生排车') + await replayDispatch(journal, '/api/platform/v1/last-leg/dispatches', proof.fullCreateRequest, created, '独立FULL创建原键恢复') + if (!created.dispatchNo || created.dispatchNo === source.dispatchNo) throw Error('FULL must be a distinct newly created native dispatch') + const fullTrip = await prepareFleetTrip(context, MODULE, created.dispatchNo, warehouseId) + trip = await prepareFleetTrip(context, MODULE, source.dispatchNo, warehouseId) + Object.assign(proof, { tripNo: trip.tripNo, fullTripNo: fullTrip.tripNo }) + full = { marker: context.marker, mode: 'FULL', dispatchNo: created.dispatchNo, customerId, transferBody: structuredClone(template) } + partial = { marker: context.marker, mode: 'PARTIAL', dispatchNo: source.dispatchNo, customerId, transferBody: structuredClone(template) } + context.createdResources.fleetFeeCoverageCases = [full, partial] + proof.fullExecution = { protocol: NATIVE_PROTOCOL, marker: context.marker, dispatchNo: full.dispatchNo, tripNo: fullTrip.tripNo } } else { - const original = await request('GET', `${dispatchBase}/${encodeURIComponent(source.dispatchNo)}`, undefined, '读取本轮原始预排实物') - if (resumeFull) check('原样本恢复前排车货物未变且未开始作业', JSON.stringify(original.items) === JSON.stringify(proof.originalDispatch.items) - && Number(original.pickedPackageCount) === 0 && Number(original.shippedPackageCount) === 0) - proof.originalDispatch = original - trip = resumeFull ? context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) - : await prepareFleetTrip(context, MODULE, source.dispatchNo, warehouseId) - proof.tripNo = trip.tripNo - const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}` - function transferBody(vehicle) { return { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), - deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', - vehiclePlate: vehicle.vehiclePlate, driverName: vehicle.driverName, driverPhone: vehicle.driverPhone, appointmentAccount: 'SELF', - remark: `${context.marker} 原费用覆盖专用验证`, fees: [{ feeType: 'DELIVERY_FEE', feeName: '未覆盖货物派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10 }] } } - let detail = await request('GET', tripPath, undefined, '读取当前派车事实供转派送使用') - check('当前车次资源及原客户排车关联仍一致', id(detail.trip.vehicleId) === id(trip.vehicleId) - && id(detail.trip.driverId) === id(trip.driverId) && detail.dispatches.some(row => row.dispatchNo === source.dispatchNo && id(row.customerId) === customerId)) - if (!resumeFull) context.createdResources.fleetFeeCoverageCases = [] - const full = resumeFull ? context.createdResources.fleetFeeCoverageCases[0] - : { marker: context.marker, mode: 'FULL', dispatchNo: source.dispatchNo, customerId, transferBody: transferBody(detail.trip) } - if (resumeFull && !resumePersisted) { - full.previousReadOnlyAttempts ||= []; full.previousReadOnlyAttempts.push(full.proof); delete full.proof - } else if (!resumeFull) context.createdResources.fleetFeeCoverageCases.push(full) - stage('FULL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, full, { reconcilePersisted: resumePersisted }) - stage('CANCEL_FULL_AND_REARRANGE') - detail = await request('GET', tripPath, undefined, '读取取消未执行全覆盖用例前车次版本') - await request('POST', `${tripPath}/dispatches/unbind`, { recordVersion: detail.trip.recordVersion, dispatchNo: full.dispatchNo, - reason: '全覆盖用例验证完毕,正式取消未执行发运并以新排车验证部分覆盖' }, '先解除未执行全覆盖排车关联') - await request('POST', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(full.proof.outboundNo)}/cancel`, - { reason: '本轮全覆盖验证完毕,正式取消未执行出库后重新安排' }, '取消未执行全覆盖出库') - await request('DELETE', `${dispatchBase}/${encodeURIComponent(full.dispatchNo)}`, undefined, '正式取消原排车释放其托盘锁') - const draftToken = key(), planPalletIds = original.items.map(row => id(row.planPalletId)) - check('原排车保留可追溯计划托盘', planPalletIds.length > 0 && planPalletIds.every(value => /^[1-9]\d*$/.test(value))) - proof.rearrange = { originalDispatchNo: full.dispatchNo, draftToken, planPalletIds } - const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken, capacity: 30 }, '建立同批当前库存的新排车草稿') - for (const planPalletId of planPalletIds) await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId, draftToken, draftContainerId: id(container.id) }, '锁定已正式释放的本轮托盘') - const arranged = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds, draftToken }, '以新排车重新安排本轮同批货物') - check('取消重排产生新排车且货量不变', arranged.dispatchNo && arranged.dispatchNo !== full.dispatchNo - && Number(arranged.packageCount) === Number(original.packageCount)) - proof.rearrange.dispatchNo = arranged.dispatchNo - detail = await request('GET', tripPath, undefined, '读取原车次当前可关联版本') - detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion, dispatchNo: arranged.dispatchNo, - reason: '本轮取消后新排车重新关联原计划车次' }, '将新排车正式关联原车次') - trip.replacedDispatchNo = trip.dispatchNo; trip.dispatchNo = arranged.dispatchNo - partial = { marker: context.marker, mode: 'PARTIAL', dispatchNo: arranged.dispatchNo, customerId, transferBody: transferBody(detail.trip) } - context.createdResources.fleetFeeCoverageCases.push(partial) - stage('PARTIAL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, partial) - stage('RESERVATIONS'); await verifyFleetBookingReservation(context, MODULE, trip, controlledPalletMeasurements) + full = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'FULL') + partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + trip = context.createdResources.fleetTrips.find(row => row.dispatchNo === proof.dispatchNo) + if (!full || !partial || partial.dispatchNo !== trip?.dispatchNo || full.dispatchNo === partial.dispatchNo) throw Error('Reviewed native FULL/PARTIAL original identities missing') + if (resumeFull && !resumePersisted && JSON.stringify(await readNativeFleetBundle(context, full.dispatchNo)) !== JSON.stringify(proof.fullBeforeAttempt)) + throw Error('Read-only FULL checkpoint changed physical state; review the original commands before any new session or write') } const access = await resolvePickerAccess(context) - let picker = null + let picker try { - if (resumePalletized || resumeReceiptConfirmed) { - const suffix = proof.pickerMarker.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() - const username = `pda_overseas_${suffix.toLowerCase()}_71` - check('仅复用本轮创建的临时PDA身份', proof.picker.username === username && access?.role?.id) - const users = await request('GET', `/api/platform/v1/iam/users?${new URLSearchParams({ page: '1', size: '200', keyword: username })}`, undefined, '核对原临时PDA账号绑定及角色') - const user = users?.records?.find(row => id(row.membershipId) === id(proof.picker.membershipId) && row.username === username) - check('原PDA仍仅有本仓作业员权限且不是受保护账号', user && id(user.tenantId) === '0' && !user.protectedAccount - && id(user.overseasWarehouseId) === warehouseId && user.roleIds?.length === 1 && id(user.roleIds[0]) === id(access.role.id)) - picker = { membershipId: user.membershipId, username, token: null } - const active = await request('PUT', `/api/platform/v1/iam/users/${encodeURIComponent(id(user.membershipId))}/status`, { status: 'ACTIVE' }, '为核对原PDA完成请求恢复本轮临时作业员') - check('原临时PDA已恢复启用', active?.status === 'ACTIVE') - const loggedIn = await request('POST', '/api/auth/login', { loginId: username, password: `Pda${suffix}A719` }, '以原生成凭据登录本轮临时PDA', '') - picker.token = loggedIn?.accessToken - } else { - const pickerContext = Object.create(context) - proof.pickerMarker ||= `${context.marker}-FPDA-${randomUUID().slice(0, 8)}` - pickerContext.marker = proof.pickerMarker - picker = access ? await createPicker(pickerContext, 71, warehouseId, access) : null + if (!resumeReceiptReady) { + const fullTrip = context.createdResources.fleetTrips.find(t => t.dispatchNo === full.dispatchNo) + stage('FULL_COVERAGE') + if (!resumed) proof.fullBeforeAttempt = await readNativeFleetBundle(context, full.dispatchNo) + let fullPicker + try { + const child = Object.create(context) + proof.fullExecution.pickerMarker = `${context.marker}-FULL-PDA-${randomUUID().slice(0,8)}` + child.marker = proof.fullExecution.pickerMarker + fullPicker = access ? await createPicker(child, 161, warehouseId, access) : null + if (!fullPicker?.token || !fullPicker.membershipId) throw Error('Independent FULL requires its own bound-warehouse PDA') + proof.fullExecution.picker = { membershipId: fullPicker.membershipId, username: fullPicker.username } + await context.checkpoint?.() + if (!resumePersisted && full.proof) { full.previousReadOnlyAttempts ??= []; full.previousReadOnlyAttempts.push(full.proof); delete full.proof } + await verifyFleetFeeCoverage(context, MODULE, full, { reconcilePersisted: resumePersisted, pickerToken: fullPicker.token, + actualMeasurements: resumePersisted ? undefined : await currentFleetMeasurements(context, fullTrip) }) + const fullDeparted = await departNativeFleet(context, fullTrip, fullPicker.token, proof.fullExecution) + proof.fullExecution.receipts = [] + for (const [index, ref] of nativeFleetDeliveries(fullDeparted.dispatchDetails[0].detail).entries()) { + const detail = await nativeRequest(context, 'GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`) + const signed = await saveReceiptAndComplete(context, detail.order, id(ref.customerId), 161+index) + if (signed?.order?.status !== 'DELIVERED' || !signed.order.signedAt) throw Error('FULL source requires actual customer POD completion') + proof.fullExecution.receipts.push({customerId:id(ref.customerId),deliveryNo:ref.deliveryNo,signedAt:signed.order.signedAt}) + } + proof.fullExecution.closure = await finishFleetTrip(context, MODULE, fullTrip) + if (proof.fullExecution.closure.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('FULL independent resources did not finish') + } finally { await disablePicker(context, fullPicker, 161) } + stage('RESERVATIONS'); await verifyFleetBookingReservation(context, MODULE, trip) } - check('专项使用本轮绑定起运仓的PDA账号', picker?.token && picker.membershipId) - proof.picker = { membershipId: picker.membershipId, username: picker.username } - stage('EXTERNAL_RECEIPT'); await verifyFleetExternalReceipt(context, MODULE, trip, picker.token, { reconcilePalletized: resumePalletized, reconcileConfirmed: resumeReceiptConfirmed }) - stage('EXTERNAL_DISPATCH'); await verifyFleetExternalDispatch(context, MODULE, trip) + if (full.proof?.status !== 'VERIFIED' || proof.fullExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' + || trip.reservation?.status !== 'VERIFIED' || partial.proof) throw Error('PARTIAL must retain its unpicked real main after independent FULL closure and reservation') + if (resumePalletized || resumeReceiptConfirmed) { + const child = Object.create(context); child.marker = proof.pickerMarker + const suffix = pickerFixtureSuffix(child), username = `pda_overseas_${suffix.toLowerCase()}_71`.slice(0, 64) + const users = await nativeRequest(context, 'GET', `/api/platform/v1/iam/users?${new URLSearchParams({page:'1',size:'200',keyword:username})}`) + const user = users.records?.find(row => id(row.membershipId) === id(proof.picker?.membershipId) && row.username === username) + if (!user || user.protectedAccount || id(user.tenantId) !== '0' || id(user.overseasWarehouseId) !== warehouseId + || user.roleIds?.length !== 1 || id(user.roleIds[0]) !== id(access?.role?.id)) throw Error('Reviewed PDA identity or warehouse changed') + picker = { membershipId: user.membershipId, username, token: null } + const active = await nativeRequest(context, 'PUT', `/api/platform/v1/iam/users/${encodeURIComponent(id(user.membershipId))}/status`, {status:'ACTIVE'}) + if (active.status !== 'ACTIVE') throw Error('Original owned PDA not active') + const login = await nativeRequest(context, 'POST', '/api/auth/login', {loginId:username,password:`Pda${suffix}A719`}, '') + picker.token = login.accessToken + } else { + const child = Object.create(context); proof.pickerMarker = `${context.marker}-FPDA-${randomUUID().slice(0,8)}`; child.marker = proof.pickerMarker + picker = access ? await createPicker(child, 71, warehouseId, access) : null + } + if (!picker?.token || !picker.membershipId) throw Error('Fresh bound-warehouse fleet PDA required') + proof.picker = {membershipId:picker.membershipId,username:picker.username}; await context.checkpoint?.() + stage('EXTERNAL_RECEIPT') + await verifyFleetExternalReceipt(context, MODULE, trip, picker.token, {reconcilePalletized:resumePalletized,reconcileConfirmed:resumeReceiptConfirmed}) + stage('EXTERNAL_DISPATCH') + await verifyFleetExternalDispatch(context, MODULE, trip, {pickerToken:picker.token, + actualMeasurements: await currentFleetMeasurements(context, trip), coverageCase:partial}) const external = trip.externalDispatch stage('FINANCE_CONSUMER') - const sources = await request('GET', `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId, businessType: 'DELIVERY_ORDER', businessNo: external.deliveryNo })}`, - undefined, '读取外接应收稳定来源编号和版本') - const target = sources?.filter(row => row.feeItemCode === 'DELIVERY_FEE' && row.direction === 'RECEIVABLE' && row.sourceStatus === 'ACTIVE') - check('外接应收来源只属于本次客户派送及实收', target?.length === 1 && target[0].businessNo === external.deliveryNo - && id(target[0].customerId) === customerId && Number(target[0].originalAmount) === 30 && target[0].originalCurrency === 'CNY') + const externalCustomer = id(external.customerId) + const sources = await nativeRequest(context, 'GET', `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({customerId:externalCustomer,businessType:'DELIVERY_ORDER',businessNo:external.deliveryNo})}`) + const target = sources.filter(row => row.feeItemCode === 'DELIVERY_FEE' && row.direction === 'RECEIVABLE' && row.sourceStatus === 'ACTIVE' + && row.inboundCode === trip.externalReceipt.incomingNo && id(row.customerId) === externalCustomer) + if (target.length !== 1 || Number(target[0].originalAmount) !== 30 || target[0].originalCurrency !== 'CNY') throw Error('Exact current-run external fee source missing') let matched = [] - for (let attempt = 0; attempt < 30; attempt++) { - const pool = await request('GET', `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`, undefined, '等待Finance消费本次外接费用') - check('Finance费用池为当前客户应收列表', Array.isArray(pool) && pool.every(row => id(row.customerId) === customerId && row.direction === 'RECEIVABLE')) - matched = pool.filter(row => id(row.sourceId) === id(target[0].sourceFeeId) && Number(row.sourceVersion) === Number(target[0].sourceVersion)) + for (let attempt=0;attempt<30;attempt++) { + const pool = await nativeRequest(context, 'GET', `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(externalCustomer)}&direction=RECEIVABLE`) + if (!Array.isArray(pool) || pool.some(row => id(row.customerId) !== externalCustomer || row.direction !== 'RECEIVABLE')) throw Error('Finance consumer scope mismatch') + matched = pool.filter(row => id(row.sourceId) === id(target[0].sourceFeeId) && id(row.sourceVersion) === id(target[0].sourceVersion)) if (matched.length) break - await wait(1000) + if (attempt<29) await wait(1000) } - check('Finance真实消费且仅一笔30CNY外接应收', matched.length === 1 && matched[0].sourceType === 'OVERSEAS_DELIVERY_FEE' - && Number(matched[0].amount) === 30 && matched[0].currency === 'CNY') - proof.finance = { status: 'CONSUMER_PERSISTED', source: target[0], pool: matched, producerAck: 'NOT_VERIFIED' } + if (matched.length !== 1 || matched[0].sourceType !== 'OVERSEAS_DELIVERY_FEE' || Number(matched[0].amount) !== 30 || matched[0].currency !== 'CNY') throw Error('Finance did not persist exactly this external fee') + proof.finance = {status:'CONSUMER_PERSISTED',customerId:externalCustomer,source:target[0],pool:matched,producerAck:'NOT_VERIFIED'} external.financeAcknowledgement = 'CONSUMER_PERSISTED_PRODUCER_ACK_NOT_VERIFIED' - stage('PICK_EACH_DISPATCH') - const dispatchNos = [partial.dispatchNo, external.dispatchNo] - proof.dispatchNos = dispatchNos - const dispatchDetails = [] - for (const [index, dispatchNo] of dispatchNos.entries()) { - const pickingContext = Object.create(context); pickingContext.marker = `${context.marker}-FLEET-PICK-${index}` - check('逐客户正式PDA拣货及完成成功', await pickAll(pickingContext, picker.token, dispatchNo)) - dispatchDetails.push(await request('GET', `${dispatchBase}/${encodeURIComponent(dispatchNo)}`, undefined, '回查逐单拣货数量及未发车事实')) - } - check('每张客户排车实际拣足但尚未发车', dispatchDetails.every(row => row.status === 'READY_TO_DEPART' - && Number(row.pickedPackageCount) === Number(row.packageCount) && Number(row.shippedPackageCount) === 0)) stage('WHOLE_TRIP_DEPARTURE') - const recordVersion = await confirmFleetLoading(context, MODULE, trip) - proof.departureRequest = { recordVersion, idempotencyKey: key(), remark: '本轮自有货与真实外接货统一发车' } - const departPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(partial.dispatchNo)}/depart` - const departed = await request('POST', departPath, proof.departureRequest, 'PDA一次命令发出全车两张客户执行单', picker.token) - check('整车发车包含所有排车且箱数守恒', departed?.tripNo === trip.tripNo && departed.status === 'IN_TRANSIT' - && JSON.stringify(departed.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify([...dispatchNos].sort()) - && Number(departed.shippedPackages) === dispatchDetails.reduce((n, row) => n + Number(row.packageCount), 0)) - const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token) - check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages)) - proof.departure = departed - await completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady }) + const departure = await departNativeFleet(context, trip, picker.token, proof) + await completeDepartedFleetScenario(context, trip, picker, departure.dispatchDetails, {costActors,onHandoverReady}) } finally { await disablePicker(context, picker, 71) } return proof } - export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady, costsAlreadyVerified = false, reviewedVehicle, verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete, closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { - const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId - const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') - const external = trip.externalDispatch, dispatchNos = proof.dispatchNos - const stage = value => { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log('FLEET_STAGE=' + value) } - const check = (label, ok) => { if (!ok) throw Error(label) } - async function request(method, path) { const r = await context.request(method, path); check('Continuation read failed', success(r)); return r.body.data } - if (!costsAlreadyVerified) { - stage('TRIP_OPERATING_COSTS') - await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) - } else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED') - stage('IN_TRANSIT_HANDOVER') - await handover(context, trip, picker.token, dispatchDetails, { - reviewedVehicle, - onReady: onHandoverReady ? async fixture => { - stage('PDA_UI_CHECKPOINT') - await onHandoverReady({ fixture, picker }) - } : undefined, - }) - stage('RECEIPTS_AND_RESOURCE_RELEASE') - proof.receipts = [] - if (context.context.fleetScenario === 'return-redispatch') { - stage('RETURN_REDISPATCH') - const returned = await verifyFleetReturnRedispatch(context, MODULE, trip, { customerId, warehouseId, pickerToken: picker.token, - dispatchNos, deliveryNos: [...partial.proof.deliveryNos, external.deliveryNo] }) - proof.receipts = returned.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) - context.context.saveFleetReturnRedispatchFixture(returned) - } else { - for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) { - const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`, - undefined, '签收前读取本次实际派送') - const completed = await completeDelivery(context, delivery.order, customerId, index + 71) - check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt) - proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt }) + const proof=context.createdResources.fleetAcceptance,customerId=proof.customerId,warehouseId=proof.warehouseId + if(proof.protocol!==NATIVE_PROTOCOL||!nativeDeparture(proof.departure,trip.dispatchNo)||proof.dispatchNo!==trip.dispatchNo + ||dispatchDetails?.length!==1||dispatchDetails[0].detail?.dispatchNo!==trip.dispatchNo)throw Error('Post-departure continuation requires one native main bundle and its actual DEPART command') + const stage=value=>{proof.stage=value;proof.checkpoints.push({stage:value,at:new Date().toISOString()});console.log('FLEET_STAGE='+value)} + if(!costsAlreadyVerified){stage('TRIP_OPERATING_COSTS');await verifyCosts(context,MODULE,trip,{...costActors,shares:fleetCostShares(trip,dispatchDetails)})} + else if(trip.costAcceptance?.status!=='VERIFIED')throw Error('Recovery cost proof must remain verified') + stage('IN_TRANSIT_HANDOVER') + await handover(context,trip,picker.token,dispatchDetails.map(bundle=>bundle.detail),{reviewedVehicle, + onReady:onHandoverReady?async fixture=>{stage('PDA_UI_CHECKPOINT');await onHandoverReady({fixture,picker})}:undefined}) + stage('RECEIPTS_AND_RESOURCE_RELEASE');proof.receipts=[] + const current=await readNativeFleetBundle(context,trip.dispatchNo),refs=nativeFleetDeliveries(current.detail) + if(context.context.fleetScenario==='return-redispatch'){ + stage('RETURN_REDISPATCH') + if(refs.some(ref=>id(ref.customerId)!==id(customerId)))throw Error('Explicit return-redispatch source selection requires the one actual source customer') + const returned=await verifyFleetReturnRedispatch(context,MODULE,trip,{customerId,warehouseId,pickerToken:picker.token, + dispatchNos:[trip.dispatchNo],deliveryNos:refs.map(ref=>ref.deliveryNo)}) + proof.receipts=returned.originalSignatures.map(row=>({customerId:id(row.customerId||customerId),deliveryNo:row.deliveryNo,signedAt:row.signedAt})) + context.context.saveFleetReturnRedispatchFixture(returned) + }else{ + for(const[index,ref]of refs.entries()){ + const delivery=await nativeRequest(context,'GET',`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`) + if(delivery.order?.deliveryNo!==ref.deliveryNo||id(delivery.order.customerId)!==id(ref.customerId)||delivery.order.dispatchNo!==trip.dispatchNo)throw Error('POD source customer or dispatch drift') + const completed=await completeDelivery(context,delivery.order,id(ref.customerId),index+71) + if(completed?.order?.status!=='DELIVERED'||!completed.order.signedAt)throw Error('每个目的地真实保存签收资料后完成') + proof.receipts.push({customerId:id(ref.customerId),deliveryNo:ref.deliveryNo,signedAt:completed.order.signedAt}) } - } - proof.closure = await closeTrip(context, MODULE, trip) - check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') - stage('HANDOVER_RESOURCE_CLEANUP') - await cleanup(context, MODULE, trip) - proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED') - return proof + } + proof.closure=await closeTrip(context,MODULE,trip) + if(proof.closure?.status!=='CARGO_COMPLETED_AND_RESOURCES_RELEASED')throw Error('本次车次货物结案及车辆司机释放完成') + stage('HANDOVER_RESOURCE_CLEANUP');await cleanup(context,MODULE,trip) + proof.status='HTTP_CHAIN_VERIFIED_WITH_ACK_GAP';stage('FINISHED');return proof } - export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors, costsAlreadyVerified = false, createdVehicle, resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 58682d9d8..916e25c99 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -8,36 +8,30 @@ import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-m import { verifyPalletLabels } from './admin-test-env-modules/phase3-loading-finalize.mjs' function departedRecoveryFixture() { - const snapshotTrip = { tripNo: 'TR-OWN', status: 'IN_TRANSIT', recordVersion: '27', vehicleId: 'V1', driverId: 'D1', warehouseId: 'W1', departedAt: '2026-10-03T11:00:00Z' } - const cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'I1', warehousePalletId: 'P1', packages: 2 }, { dispatchNo: 'EXT', dispatchItemId: 'I2', warehousePalletId: 'P2', packages: 1 }] - const departure = { action: 'DEPART', recordVersion: '27', snapshot: { trip: snapshotTrip, confirmedLoad: { manifestHash: 'a'.repeat(64), cargo } } } - const proof = { marker: 'RUN', tripNo: 'TR-OWN', warehouseId: 'W1', customerId: 'C1', status: 'CHECKING', stage: 'TRIP_OPERATING_COSTS', checkpoints: [], - dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' }, - departure: { tripNo: 'TR-OWN', status: 'IN_TRANSIT', departedAt: snapshotTrip.departedAt, shippedPackages: 3 } } - const trip = { marker: 'RUN', tripNo: 'TR-OWN', costAcceptance: { marker: 'RUN', status: 'FAILED', costs: [], departure, - requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't' }] }, - externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } } - const report = { success: false, steps: [{ name: '独立预期覆盖全部实物且没有重叠托盘', passed: false }, { name: '模块执行', passed: false }], - createdResources: { fleetAcceptance: proof, fleetTrips: [trip], fleetCostActors: { status: 'DISABLED' }, fleetFeeCoverageCases: [{ mode: 'PARTIAL', proof: { deliveryNos: ['DL-OWN'] } }] } } - const base = '/api/platform/v1/last-leg/fleet/trips/TR-OWN' - const responses = new Map([[base, { trip: structuredClone(snapshotTrip), dispatches: [{ dispatchNo: 'OWN', customerId: 'C1' }, { dispatchNo: 'EXT', customerId: 'C1' }] }], - [base + '/history?page=1&size=100', { total: 1, records: [structuredClone(departure)] }], - [base + '/costs?page=1&size=100', { page: { records: [], total: 0 }, summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [] } }], - [base + '/handovers', { records: [], total: 0 }]]) - for (const row of cargo) { - responses.set('/api/platform/v1/last-leg/dispatches/' + row.dispatchNo, { dispatchNo: row.dispatchNo, status: 'DEPARTED', overseasWarehouseId: 'W1', - packageCount: row.packages, pickedPackageCount: row.packages, shippedPackageCount: row.packages, - items: [{ id: row.dispatchItemId, warehousePalletId: row.warehousePalletId, packageCount: row.packages }] }) - responses.set('/api/platform/v1/last-leg/delivery-orders/DL-' + row.dispatchNo + '?customerId=C1', { order: { deliveryNo: 'DL-' + row.dispatchNo, - customerId: 'C1', dispatchNo: row.dispatchNo, status: 'WAIT_DELIVERY', version: '1', packageCount: row.packages, - documentSummary: { categories: [{ fileCount: 0 }] } } }) - } - const calls = [], ctx = { marker: 'RUN', token: 'writer', context: {}, createdResources: report.createdResources, async checkpoint() { calls.push('checkpoint') }, - async request(method, path) { assert.equal(method, 'GET', 'recovery verification must never write'); calls.push(path); assert.ok(responses.has(path), path) - return { http: 200, body: { code: '200', data: responses.get(path) }, requestId: 'r', traceId: 't' } } } - return { report, ctx, calls, responses, base, trip } + const snapshotTrip={tripNo:'TR-OWN',status:'IN_TRANSIT',recordVersion:'27',vehicleId:'V1',driverId:'D1',warehouseId:'W1',departedAt:'2026-10-03T11:00:00Z'} + const cargo=[{dispatchNo:'OWN',dispatchItemId:'I1',customerId:'C1',warehousePalletId:'P1',packages:2},{dispatchNo:'OWN',dispatchItemId:'I2',customerId:'C1',warehousePalletId:'P2',packages:1}] + const measured=[{warehousePalletId:'P1',palletSpaces:2},{warehousePalletId:'P2',palletSpaces:1}] + const departure={action:'DEPART',recordVersion:'27',snapshot:{trip:snapshotTrip,confirmedLoad:{manifestHash:'a'.repeat(64),cargo,pallets:measured}}} + const refs=[{customerId:'C1',deliveryNo:'DL-OWN',outboundNo:'OUT-C1',packageCount:2},{customerId:'C1',deliveryNo:'DL-EXT',outboundNo:'OUT-C1',packageCount:1}] + const detail={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:3,pickedPackageCount:3,shippedPackageCount:3},documents:{deliveries:refs}} + const items={dispatchNo:'OWN',recordVersion:'31',items:cargo.map(row=>({dispatchItemId:row.dispatchItemId,customerId:row.customerId,warehousePalletId:row.warehousePalletId,scheduledPackages:row.packages,pickedPackages:row.packages}))} + const orders=refs.map(ref=>({...ref,dispatchNo:'OWN',status:'DELIVERING',version:'1',documentSummary:{categories:[{fileCount:0}]}})) + const proof={protocol:'NATIVE_DISPATCH_V1',marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',customerId:'C1',status:'CHECKING',stage:'TRIP_OPERATING_COSTS',checkpoints:[], + dispatchNos:['OWN'],picker:{membershipId:'OLD-PDA'},departureRequest:{idempotencyKey:'old-key'},departureSnapshot:{detail:structuredClone(detail),items:structuredClone(items)},departureDeliveries:structuredClone(orders), + departure:{action:'DEPART',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',result:{departedAt:snapshotTrip.departedAt,shippedPackages:3,deliveries:refs}}} + const trip={marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',confirmedLoad:{manifestHash:'a'.repeat(64),pallets:measured},costAcceptance:{marker:'RUN',status:'FAILED',costs:[],departure, + requests:[{method:'GET',status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t'}]},externalReceipt:{status:'VERIFIED',bookingNo:'BK',warehousePalletId:'P2'},externalDispatch:{dispatchNo:'OWN',deliveryNo:'DL-EXT'}} + const report={success:false,steps:[{name:'独立预期覆盖全部实物且没有重叠托盘',passed:false},{name:'模块执行',passed:false}],createdResources:{fleetAcceptance:proof,fleetTrips:[trip],fleetCostActors:{status:'DISABLED'},fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DL-OWN','DL-EXT']}}]}} + const base='/api/platform/v1/last-leg/fleet/trips/TR-OWN' + const responses=new Map([[base,{trip:structuredClone(snapshotTrip),dispatches:[{dispatchNo:'OWN'}]}], + [base+'/history?page=1&size=100',{total:1,records:[structuredClone(departure)]}], + [base+'/costs?page=1&size=100',{page:{records:[],total:0},summary:{manifestHash:'a'.repeat(64),departureVersion:'27',totals:[]}}],[base+'/handovers',{records:[],total:0}], + ['/api/platform/v1/last-leg/dispatches/OWN',structuredClone(detail)],['/api/platform/v1/last-leg/dispatches/OWN/items',structuredClone(items)]]) + for(const order of orders)responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`,{order:structuredClone(order)}) + const calls=[],ctx={marker:'RUN',token:'writer',context:{},createdResources:report.createdResources,async checkpoint(){calls.push('checkpoint')}, + async request(method,path){assert.equal(method,'GET','recovery verification must never write');calls.push(path);assert.ok(responses.has(path),path);return {http:200,body:{code:'200',data:responses.get(path)},requestId:'r',traceId:'t'}}} + return {report,ctx,calls,responses,base,trip} } - function createdVehicleRecoveryFixture() { const f = afterCostsRecoveryFixture(), snapshot = { id: '2106360046045675522', plateNumber: 'HO-24255909-3BC', recordVersion: '0', status: 'IN_SERVICE', ownership: 'OWNED', region: 'OVERSEAS', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } @@ -51,57 +45,40 @@ function createdVehicleRecoveryFixture() { } function returnSourceRecoveryFixture() { - const f = afterCostsRecoveryFixture(), { report, trip, responses, base, ctx } = f, run = report.createdResources.fleetAcceptance - run.stage = 'RETURN_REDISPATCH'; run.receipts = []; run.departure.shippedPackages = 5 - report.steps = [{ name: '模块执行', passed: false, evidence: 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' }] - const current = { ...responses.get(base).trip, recordVersion: '30', vehicleId: 'V2', driverId: 'D2', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } - responses.get(base).trip = current - const original = trip.costAcceptance.departure - original.snapshot.confirmedLoad.cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'IA', warehousePalletId: 'PA', packages: 3 }, - { dispatchNo: 'OWN', dispatchItemId: 'IB', warehousePalletId: 'PB', packages: 1 }, { dispatchNo: 'EXT', dispatchItemId: 'IX', warehousePalletId: 'PX', packages: 1 }] - const history = { records: [structuredClone(original), { action: 'HANDOVER', recordVersion: '28' }, - { action: 'RELEASE', recordVersion: '29' }, { action: 'RELEASE', recordVersion: '30' }], total: 4 } - responses.set(base + '/history?page=1&size=100', history) - const handover = { records: [{ handoverNo: 'HO-CURRENT', recordVersion: '2' }], total: 1 } - const vehicle = { id: 'V2', recordVersion: '0', plateNumber: 'NEW', status: 'IN_SERVICE' } - const driver = { id: 'D2', recordVersion: '2', active: true, reviewStatus: 'APPROVED', licenseFileId: 'FILE', details: {} } - trip.handover = { status: 'VERIFIED', finalTrip: structuredClone(current), handoverNo: 'HO-CURRENT', requests: [ - { method: 'GET', path: base + '/history?page=1&size=100', result: structuredClone(history) }, - { method: 'GET', path: base + '/handovers?page=1&size=10', result: structuredClone(handover) }, - { method: 'POST', path: '/api/platform/v1/last-leg/master-data/vehicles', result: structuredClone(vehicle) }, - { method: 'POST', path: '/api/platform/v1/last-leg/fleet/drivers/D2/review', result: structuredClone(driver) }] } - responses.set(base + '/handovers', handover); responses.set('/api/platform/v1/last-leg/master-data/vehicles/V2', vehicle) - responses.set('/api/platform/v1/last-leg/fleet/drivers/D2', driver) - ctx.context.readRecoveryTripResources = async () => [{ resourceKind: 'VEHICLE', resourceId: 'V2', tripNo: 'TR-OWN' }, { resourceKind: 'DRIVER', resourceId: 'D2', tripNo: 'TR-OWN' }] - const rows = [['A', 3, 'OWN'], ['B', 1, 'OWN'], ['EXT', 1, 'EXT']] - const originals = rows.map(([label, packages, dispatchNo]) => ({ deliveryNo: 'DL-' + label, outboundNo: 'OUT-' + label, dispatchNo, - customerId: 'C1', overseasWarehouseId: 'W1', status: 'WAIT_DELIVERY', version: '2', packageCount: packages, - documentSummary: { categories: [{ fileCount: 0 }] } })) - trip.returnRedispatch = { marker: 'RUN', status: 'FAILED', failure: report.steps[0].evidence, originalDeliveries: originals, - requests: originals.map(order => ({ method: 'PUT', path: `/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/status?customerId=C1`, - body: { status: 'DELIVERING', expectedVersion: 2 }, status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't', - result: { deliveryNo: order.deliveryNo, deliveryStatus: 'DELIVERING', replayed: false } })) } - report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos = ['DL-A', 'DL-B'] - for (const [label, packages, dispatchNo] of rows) { - const order = originals.find(row => row.deliveryNo === 'DL-' + label) - responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`, { order: { ...structuredClone(order), - status: 'DELIVERING', version: '3', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } }) - const relation = { lastMileOrderId: label, inboundCode: 'IN-' + label, deliveryNo: order.deliveryNo, outboundNo: order.outboundNo, - returnSources: [{ maxReturnPackages: packages }] } - responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/return-inbound-options?customerId=C1`, [relation]) - responses.set(`/api/platform/v1/last-leg/orders/${label}?customerId=C1`, { order: { id: label, customerId: 'C1', overseasWarehouseId: 'W1', - orderType: label === 'EXT' ? 'WAREHOUSE_TRANSFER' : 'FIRST_LEG', sourceInboundId: 'SOURCE-' + label, inboundCode: relation.inboundCode, packageCount: packages } }) - } - for (const no of ['OWN', 'EXT']) { - const cargo = original.snapshot.confirmedLoad.cargo.filter(row => row.dispatchNo === no), total = cargo.reduce((n,row)=>n+row.packages,0) - responses.set('/api/platform/v1/last-leg/dispatches/' + no, { dispatchNo: no, status: 'DEPARTED', overseasWarehouseId: 'W1', - packageCount: total, pickedPackageCount: total, shippedPackageCount: total, - items: cargo.map(row=>({id:row.dispatchItemId,warehousePalletId:row.warehousePalletId,packageCount:row.packages})) }) + const f=afterCostsRecoveryFixture(),{report,trip,responses,base,ctx}=f,run=report.createdResources.fleetAcceptance + run.stage='RETURN_REDISPATCH';run.receipts=[];run.departure.result.shippedPackages=5 + report.steps=[{name:'模块执行',passed:false,evidence:'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B'}] + const current={...responses.get(base).trip,recordVersion:'30',vehicleId:'V2',driverId:'D2',vehiclePlate:'NEW',driverName:'NEW',driverPhone:'NEW'} + responses.get(base).trip=current + const original=trip.costAcceptance.departure + original.snapshot.confirmedLoad.cargo=[{dispatchNo:'OWN',customerId:'C1',dispatchItemId:'IA',warehousePalletId:'PA',packages:3}, + {dispatchNo:'OWN',customerId:'C1',dispatchItemId:'IB',warehousePalletId:'PB',packages:1},{dispatchNo:'OWN',customerId:'C1',dispatchItemId:'IX',warehousePalletId:'PX',packages:1}] + const history={records:[structuredClone(original),{action:'HANDOVER',recordVersion:'28'},{action:'RELEASE',recordVersion:'29'},{action:'RELEASE',recordVersion:'30'}],total:4} + responses.set(base+'/history?page=1&size=100',history) + const handover={records:[{handoverNo:'HO-CURRENT',recordVersion:'2'}],total:1},vehicle={id:'V2',recordVersion:'0',plateNumber:'NEW',status:'IN_SERVICE'},driver={id:'D2',recordVersion:'2',active:true,reviewStatus:'APPROVED',licenseFileId:'FILE',details:{}} + trip.handover={status:'VERIFIED',finalTrip:structuredClone(current),handoverNo:'HO-CURRENT',requests:[ + {method:'GET',path:base+'/history?page=1&size=100',result:structuredClone(history)},{method:'GET',path:base+'/handovers?page=1&size=10',result:structuredClone(handover)}, + {method:'POST',path:'/api/platform/v1/last-leg/master-data/vehicles',result:structuredClone(vehicle)},{method:'POST',path:'/api/platform/v1/last-leg/fleet/drivers/D2/review',result:structuredClone(driver)}]} + responses.set(base+'/handovers',handover);responses.set('/api/platform/v1/last-leg/master-data/vehicles/V2',vehicle);responses.set('/api/platform/v1/last-leg/fleet/drivers/D2',driver) + ctx.context.readRecoveryTripResources=async()=>[{resourceKind:'VEHICLE',resourceId:'V2',tripNo:'TR-OWN'},{resourceKind:'DRIVER',resourceId:'D2',tripNo:'TR-OWN'}] + const rows=[['A',3],['B',1],['EXT',1]] + const originals=rows.map(([label,packages])=>({deliveryNo:'DL-'+label,outboundNo:'OUT-C1',dispatchNo:'OWN',customerId:'C1',overseasWarehouseId:'W1',status:'DELIVERING',version:'3',packageCount:packages,documentSummary:{categories:[{fileCount:0}]}})) + trip.returnRedispatch={marker:'RUN',status:'FAILED',failure:report.steps[0].evidence,originalDeliveries:originals, + requests:originals.map(order=>({method:'GET',path:`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`,status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t',result:{order:structuredClone(order)}}))} + report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos=['DL-A','DL-B','DL-EXT'] + const detail={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:5,pickedPackageCount:5,shippedPackageCount:5},documents:{deliveries:originals.map(o=>({deliveryNo:o.deliveryNo,outboundNo:o.outboundNo,customerId:o.customerId,packageCount:o.packageCount}))}} + const items={dispatchNo:'OWN',recordVersion:'31',items:original.snapshot.confirmedLoad.cargo.map(c=>({dispatchItemId:c.dispatchItemId,customerId:c.customerId,warehousePalletId:c.warehousePalletId,scheduledPackages:c.packages,pickedPackages:c.packages}))} + run.departureSnapshot={detail:structuredClone(detail),items:structuredClone(items)} + responses.set('/api/platform/v1/last-leg/dispatches/OWN',detail);responses.set('/api/platform/v1/last-leg/dispatches/OWN/items',items) + for(const[label,packages]of rows){const order=originals.find(o=>o.deliveryNo==='DL-'+label) + responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`,{order:{...structuredClone(order),vehiclePlate:'NEW',driverName:'NEW',driverPhone:'NEW'}}) + const relation={lastMileOrderId:label,inboundCode:'IN-'+label,deliveryNo:order.deliveryNo,outboundNo:order.outboundNo,returnSources:[{maxReturnPackages:packages}]} + responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/return-inbound-options?customerId=C1`,[relation]) + responses.set(`/api/platform/v1/last-leg/orders/${label}?customerId=C1`,{order:{id:label,customerId:'C1',overseasWarehouseId:'W1',orderType:label==='EXT'?'WAREHOUSE_TRANSFER':'FIRST_LEG',sourceInboundId:'SOURCE-'+label,inboundCode:relation.inboundCode,packageCount:packages}}) } return f } - -test('source-selection recovery only accepts original three explicit starts and rejects any actual return or unsigned result', () => { +test('source-selection recovery accepts three definitive native reads and rejects any actual return or unknown result', () => { const f = returnSourceRecoveryFixture() assert.equal(reviewedFullCheckpoint(f.report, 'RETURN_SOURCE_SELECTION_BEFORE_RETURN').returnSourceSelection, true) for (const change of [x=>x.trip.returnRedispatch.requests.push({ method:'POST',path:'/mark-return' }), @@ -226,7 +203,7 @@ for (const mutation of ['none', 'version', 'history', 'cost', 'handover', 'cargo if (mutation === 'history') responses.get(base + '/history?page=1&size=100').records[0].snapshot.confirmedLoad.manifestHash = 'changed' if (mutation === 'cost') responses.get(base + '/costs?page=1&size=100').page.records.push({ costNo: 'COST' }) if (mutation === 'handover') responses.get(base + '/handovers').records.push({ handoverNo: 'H' }) - if (mutation === 'cargo') responses.get('/api/platform/v1/last-leg/dispatches/OWN').items[0].packageCount = 3 + if (mutation === 'cargo') responses.get('/api/platform/v1/last-leg/dispatches/OWN/items').items[0].scheduledPackages = 3 const delivery = responses.get('/api/platform/v1/last-leg/delivery-orders/DL-OWN?customerId=C1').order if (mutation === 'signature') delivery.signedAt = 'now' if (mutation === 'document') delivery.documentSummary.categories[0].fileCount = 1 @@ -241,7 +218,7 @@ test('reviewed recovery archives old evidence and creates fresh sessions before resolveAccess: async () => ({}), makePicker: async child => { assert.match(child.marker, /RECOVERY-PDA/); calls.push('new-picker'); return { membershipId: 'NEW', token: 'new-pda' } }, closePicker: async () => calls.push('close-picker'), complete: async (context, actualTrip, picker, dispatches) => { - assert.equal(actualTrip, trip); assert.equal(trip.costAcceptance, undefined); assert.equal(picker.membershipId, 'NEW'); assert.equal(dispatches.length, 2) + assert.equal(actualTrip, trip); assert.equal(trip.costAcceptance, undefined); assert.equal(picker.membershipId, 'NEW'); assert.equal(dispatches.length, 1) assert.equal(trip.previousReadOnlyCostAttempts[0], oldCost); assert.equal(context.createdResources.fleetAcceptance.previousPicker.membershipId, 'OLD-PDA') calls.push('continue-from-costs') }, @@ -283,7 +260,7 @@ test('changed source report SHA blocks recovery before creating context or loggi rmSync(temporary, { recursive: true, force: true }) } }) -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation, verifyReturnSourceRecovery, continueReviewedReturnSources } from './fleet-test-env-acceptance.mjs' +import { selectNativeFleetFullSource, nativeFleetDeliveries, departNativeFleet, currentFleetMeasurements, parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation, verifyReturnSourceRecovery, continueReviewedReturnSources } from './fleet-test-env-acceptance.mjs' for (const response of [ { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, @@ -302,6 +279,7 @@ for (const response of [ body.expectedVersion = 99 assert.equal(saved[0].body.expectedVersion, 6) assert.equal(saved[1].status, 'UNKNOWN_RESULT') + assert.equal(ctx.createdResources.fleetWriteBoundary.status, 'STOPPED') for (const method of ['POST', 'GET', 'DELETE']) await assert.rejects(ctx.request(method, '/must-not-run'), /Unknown write outcome/) assert.deepEqual(calls, ['/orders/1/remarks?tenantId=2']) }) @@ -481,10 +459,10 @@ test('return redispatch is explicit, fresh-only and declares source-event proof }) test('handover stage exports only a verified same-run departure fixture before the optional UI checkpoint', async () => { - const order = [], departure = { tripNo: 'TR-NEW', status: 'IN_TRANSIT' } + const order = [], departure = { action:'DEPART', dispatchNo:'PC-NEW', status:'ACTIVE', stage:'DELIVERING', result:{departedAt:'2026-10-19T00:00:00Z',shippedPackages:1} } const context = { marker: 'CURRENT', context: { saveFleetHandoverFixture(value) { order.push('save'); assert.equal(value.marker, 'CURRENT') } }, - createdResources: { fleetAcceptance: { departure } } } - const trip = { tripNo: 'TR-NEW', warehouseId: '92', costAcceptance: { status: 'VERIFIED', marker: 'CURRENT', expected: { finalCny: '100.03', usd: '13.07' } } }, details = [{ dispatchNo: 'PC-NEW' }] + createdResources: { fleetAcceptance: { tripNo:'TR-NEW', dispatchNo:'PC-NEW', departure } } } + const trip = { tripNo: 'TR-NEW', dispatchNo:'PC-NEW', warehouseId: '92', costAcceptance: { status: 'VERIFIED', marker: 'CURRENT', expected: { finalCny: '100.03', usd: '13.07' } } }, details = [{ dispatchNo: 'PC-NEW' }] const verified = { status: 'VERIFIED', marker: 'CURRENT', originalDeparture: { snapshot: { confirmedLoad: { pallets: [] } } }, browser: { tripNo: 'TR-NEW' }, handoverNo: 'HO-NEW' } const result = await runFleetHandoverStage(context, trip, 'secret-picker-token', details, { @@ -499,16 +477,22 @@ test('handover stage exports only a verified same-run departure fixture before t } }) -test('cost shares use controlled physical pallets and the original external booking, never statistical totals', () => { - const trip = { externalReceipt: { status: 'VERIFIED', bookingNo: 'BK-ONE', warehousePalletId: '3' }, externalDispatch: { dispatchNo: 'EXT' } } - const details = [{ dispatchNo: 'OWN', items: [{ warehousePalletId: '1' }, { warehousePalletId: '1' }, { warehousePalletId: '2' }] }, - { dispatchNo: 'EXT', items: [{ warehousePalletId: '3' }] }] +test('one dispatch cost shares use actual physical measurements and the original external booking', () => { + const trip = { dispatchNo: 'OWN', externalReceipt: { status: 'VERIFIED', bookingNo: 'BK-ONE', warehousePalletId: '3' }, + externalDispatch: { dispatchNo: 'OWN' }, confirmedLoad: { manifestHash: 'ACTUAL', pallets: [ + { warehousePalletId: '1', palletSpaces: 2 }, { warehousePalletId: '2', palletSpaces: 3 }, { warehousePalletId: '3', palletSpaces: 4 }] } } + const details = [{ detail: { dispatchNo: 'OWN', recordVersion: '0' }, items: { dispatchNo: 'OWN', recordVersion: '0', + items: [{ warehousePalletId: '1', customerId: 'A' }, { warehousePalletId: '1', customerId: 'B' }, + { warehousePalletId: '2', customerId: 'A' }, { warehousePalletId: '3', customerId: 'B' }] } }] assert.deepEqual(fleetCostShares(trip, details), [ - { businessNo: 'OWN', sourceType: 'OWN', dispatchNo: 'OWN', warehousePalletIds: ['1', '2'], palletSpaces: 2 }, - { businessNo: 'BK-ONE', sourceType: 'EXTERNAL', dispatchNo: 'EXT', warehousePalletIds: ['3'], palletSpaces: 1 }, + { businessNo: 'OWN', sourceType: 'OWN', dispatchNo: 'OWN', warehousePalletIds: ['1', '2'], palletSpaces: 5 }, + { businessNo: 'BK-ONE', sourceType: 'EXTERNAL', dispatchNo: 'OWN', warehousePalletIds: ['3'], palletSpaces: 4 }, ]) - assert.throws(() => fleetCostShares(trip, [{ ...details[0], items: [{ warehousePalletId: '3' }] }, details[1]]), /distinct/) - assert.throws(() => fleetCostShares(trip, [details[0], { ...details[1], items: [{ warehousePalletId: '4' }] }]), /receipt/) + assert.throws(() => fleetCostShares({ ...trip, confirmedLoad: null }, details), /measurements/) + assert.throws(() => fleetCostShares(trip, [{ ...details[0], items: { ...details[0].items, recordVersion: '1' } }]), /version/) + assert.throws(() => fleetCostShares({ ...trip, externalReceipt: { ...trip.externalReceipt, warehousePalletId: '4' } }, details), /distinct/) + assert.throws(() => fleetCostShares({ ...trip, confirmedLoad: { ...trip.confirmedLoad, + pallets: [...trip.confirmedLoad.pallets, trip.confirmedLoad.pallets[0]] } }, details), /measurements/) }) test('missing isolated cost actor preparation rejects before readers or fixture creation', async () => { @@ -592,16 +576,18 @@ test('phone lookup failure stops without any user creation or guessing', async ( assert.equal(calls, 1) }) -test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => { - const fullSource = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') - const source = fullSource.slice(fullSource.indexOf('export async function runFleetScenario')) - const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')") - const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip') - const cleanup = source.indexOf('await cleanup(context, MODULE, trip)') - assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup) - assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/) +test('main chain performs handover after actual native DEPART and before receipts, and cleanup follows current-resource closure', async () => { + const fullSource=readFileSync(new URL('./fleet-test-env-acceptance.mjs',import.meta.url),'utf8') + const source=fullSource.slice(fullSource.indexOf('export async function runFleetScenario')) + assert.ok(source.indexOf('const departure = await departNativeFleet')0&&handover { test('checkpoint guard forbids repeating commands whose write outcome may be unknown', () => { const report = { success: false, createdResources: { - fleetAcceptance: { marker: 'M', stage: 'FULL_COVERAGE', status: 'CHECKING', tripNo: 'T', originalDispatch: { dispatchNo: 'D' } }, - fleetTrips: [{ tripNo: 'T', dispatchNo: 'D' }], - fleetFeeCoverageCases: [{ mode: 'FULL', marker: 'M', dispatchNo: 'D', proof: { status: 'CHECKING', requests: [{ method: 'GET' }] } }], + fleetAcceptance: { protocol:'NATIVE_DISPATCH_V1', dispatchNo:'D2', dispatchNos:['D2'], fullTripNo:'F', marker: 'M', stage: 'FULL_COVERAGE', status: 'CHECKING', tripNo: 'T', originalDispatch: { detail:{dispatchNo:'D2'} } }, + fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2' },{tripNo:'F',dispatchNo:'D'}], + fleetFeeCoverageCases: [{ mode: 'FULL', marker: 'M', dispatchNo: 'D', proof: { status: 'CHECKING', requests: [{ method: 'GET',status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t' }] } },{mode:'PARTIAL',marker:'M',dispatchNo:'D2'}], } } assert.equal(reviewedFullCheckpoint(report).marker, 'M') const mutated = structuredClone(report) @@ -719,16 +705,19 @@ test('checkpoint guard forbids repeating commands whose write outcome may be unk mutated.createdResources.fleetFeeCoverageCases[0].proof.outboundNo = 'OUT-1' assert.throws(() => reviewedFullCheckpoint(mutated), /unknown outcomes/) assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) - Object.assign(mutated.createdResources.fleetFeeCoverageCases[0].proof, { deliveryNos: ['DEL-1'], confirmationRequest: { idempotencyKey: 'original-key' } }) - mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ label: '正式保存本次原费用服务覆盖并转派送', method: 'POST', http: 200, code: '200' }) + Object.assign(mutated.createdResources.fleetFeeCoverageCases[0].proof, { deliveryNos: ['DEL-1'], confirmationRequest: { recordVersion:0,groups:[{groupKey:'G'}],idempotencyKey: 'original-key' } }) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ path:'/api/platform/v1/last-leg/dispatches/D/transfer-delivery',label: '正式保存本次原费用服务覆盖并转派送', method: 'POST',status:'RESPONDED',requestId:'r',traceId:'t',http: 200, code: '200', + body: structuredClone(mutated.createdResources.fleetFeeCoverageCases[0].proof.confirmationRequest), result: { action:'ARRANGE_DELIVERY',dispatchNo:'D',status:'ACTIVE',stage:'AFTER_LOADING',result:{outbounds:[{outboundNo:'OUT-1'}],deliveries:[{deliveryNo:'DEL-1'}]} } }) assert.equal(reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED').persisted, true) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.at(-1).body.idempotencyKey = 'different-key' + assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) }) test('receipt-ready reconciliation cannot repeat a receipt that has already started', () => { const report = { success: false, steps: [{ name: '创建海外PDA仓库操作员71', code: 'A0409', message: '手机号已存在' }], createdResources: { - fleetAcceptance: { marker: 'M', stage: 'RESERVATIONS', status: 'CHECKING', tripNo: 'T' }, + fleetAcceptance: { protocol:'NATIVE_DISPATCH_V1',dispatchNo:'D2',dispatchNos:['D2'],fullExecution:{closure:{status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED'}},marker: 'M', stage: 'RESERVATIONS', status: 'CHECKING', tripNo: 'T' }, fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2', reservation: { status: 'VERIFIED' } }], - fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2', proof: { status: 'VERIFIED' } }], + fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2' }], } } assert.equal(reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_READY').receiptReady, true) report.createdResources.fleetTrips[0].externalReceipt = { status: 'CHECKING' } @@ -737,12 +726,12 @@ test('receipt-ready reconciliation cannot repeat a receipt that has already star test('palletized checkpoint requires a saved successful command, owned operator, and no later confirmation', () => { const report = { success: false, createdResources: { - fleetAcceptance: { marker: 'M', stage: 'EXTERNAL_RECEIPT', status: 'CHECKING', tripNo: 'T', picker: { membershipId: '71' }, pickerMarker: 'M-PDA' }, + fleetAcceptance: { protocol:'NATIVE_DISPATCH_V1',dispatchNo:'D2',dispatchNos:['D2'],fullExecution:{closure:{status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED'}},marker: 'M', stage: 'EXTERNAL_RECEIPT', status: 'CHECKING', tripNo: 'T', picker: { membershipId: '71' }, pickerMarker: 'M-PDA' }, fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2', reservation: { status: 'VERIFIED' }, externalReceipt: { status: 'CHECKING', warehousePalletId: 'P1', palletizeRequest: { idempotencyKey: 'original' }, requests: [ { label: 'PDA正式完成外接收货及库存入账', method: 'POST', http: 200, code: '200' }, ] } }], - fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2', proof: { status: 'VERIFIED' } }], + fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2' }], } } assert.equal(reviewedFullCheckpoint(report, 'EXTERNAL_PALLETIZED').palletized, true) assert.throws(() => reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_CONFIRMED'), /exact successful PDA command/) @@ -765,35 +754,32 @@ test('palletized checkpoint requires a saved successful command, owned operator, }) function terminalFixture(fault = '') { - const finalTrip = { status: 'COMPLETED', completedAt: '2026-10-02T22:01:00Z', recordVersion: '30', vehicleRelease: { endLocation: 'ONT8' }, driverRelease: { endLocation: 'ONT8' } } - const closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED', finalTrip, probes: [{ tripNo: 'PROBE' }] } - const trip = { tripNo: 'T', closure, externalReceipt: { status: 'VERIFIED' }, reassignment: { status: 'VERIFIED' }, externalDispatch: { status: 'VERIFIED', dispatchNo: 'EXT', deliveryNo: 'DEL-EXT' } } - const proof = { marker: 'M', tripNo: 'T', customerId: '92', stage: 'RECEIPTS_AND_RESOURCE_RELEASE', closure, - receipts: ['DEL-OWN', 'DEL-EXT'].map(deliveryNo => ({ deliveryNo, signedAt: '2026-10-02T22:00:00' })), - departure: { shippedPackages: 6 }, finance: { status: 'CONSUMER_PERSISTED', source: { sourceFeeId: 'F1', sourceVersion: 1 } } } - const resources = { fleetAcceptance: proof, fleetTrips: [trip], fleetFeeCoverageCases: [{ mode: 'PARTIAL', dispatchNo: 'OWN', proof: { deliveryNos: ['DEL-OWN'] } }] } - const calls = [] - const context = { createdResources: resources, assert() {}, async request(method, path) { - calls.push({ method, path }); assert.equal(method, 'GET') - let data - if (path.endsWith('/trips/T')) data = { trip: finalTrip, dispatches: ['OWN', 'EXT'].map(dispatchNo => ({ dispatchNo })) } - else if (path.endsWith('/trips/PROBE')) data = { trip: { status: 'CANCELLED' } } - else if (path.includes('/dispatches/')) { const number = path.split('/').at(-1), count = number === 'OWN' ? 5 : 1 - data = { dispatchNo: number, status: 'DEPARTED', packageCount: count, pickedPackageCount: count, shippedPackageCount: fault === 'quantity' ? 0 : count } } - else if (path.includes('/delivery-orders/')) data = { order: { deliveryNo: path.split('/').at(-1).split('?')[0], status: fault === 'unsigned' ? 'WAIT_DELIVERY' : 'DELIVERED', signedAt: '2026-10-02T22:00:00' } } - else if (path.includes('/fee-pool?')) data = [{ customerId: '92', sourceId: 'F1', sourceVersion: fault === 'wrong-fee-version' ? 2 : 1, amount: 30, currency: 'CNY', sourceType: 'OVERSEAS_DELIVERY_FEE' }] + const finalTrip={status:'COMPLETED',completedAt:'2026-10-02T22:01:00Z',recordVersion:'30',vehicleRelease:{endLocation:'ONT8'},driverRelease:{endLocation:'ONT8'}} + const closure={status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED',finalTrip,subsequentTripClaimProtection:'VERIFIED_NATIVE_EMPTY_DISPATCH',nextDispatch:{dispatchNo:'PROBE',finalStatus:'CANCELLED'}} + const trip={tripNo:'T',dispatchNo:'OWN',closure,externalReceipt:{status:'VERIFIED'},reassignment:{status:'REJECTED_WITHOUT_MUTATION'},externalDispatch:{status:'VERIFIED',dispatchNo:'OWN',deliveryNo:'DEL-EXT'}} + const refs=[{customerId:'92',deliveryNo:'DEL-OWN',outboundNo:'OUT',packageCount:5},{customerId:'92',deliveryNo:'DEL-EXT',outboundNo:'OUT',packageCount:1}] + const proof={protocol:'NATIVE_DISPATCH_V1',marker:'M',tripNo:'T',dispatchNo:'OWN',dispatchNos:['OWN'],customerId:'92',stage:'RECEIPTS_AND_RESOURCE_RELEASE',closure, + receipts:refs.map(row=>({customerId:'92',deliveryNo:row.deliveryNo,signedAt:'2026-10-02T22:00:00'})), + departure:{action:'DEPART',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',result:{departedAt:'2026-10-02T20:00:00Z',shippedPackages:6}},finance:{customerId:'92',status:'CONSUMER_PERSISTED',source:{sourceFeeId:'F1',sourceVersion:1}}} + const resources={fleetAcceptance:proof,fleetTrips:[trip],fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DEL-OWN','DEL-EXT']}}]} + const calls=[],context={createdResources:resources,assert(){},async request(method,path){ + calls.push({method,path});assert.equal(method,'GET');let data + if(path.endsWith('/trips/T'))data={trip:finalTrip,dispatches:[{dispatchNo:'OWN'}]} + else if(path.endsWith('/dispatches/PROBE'))data={dispatchNo:'PROBE',status:'CANCELLED',counts:{packageCount:0,pickedPackageCount:0}} + else if(path.endsWith('/dispatches/OWN'))data={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERED',counts:{packageCount:6,pickedPackageCount:6,shippedPackageCount:fault==='quantity'?0:6},documents:{deliveries:refs}} + else if(path.includes('/delivery-orders/'))data={order:{customerId:'92',deliveryNo:path.split('/').at(-1).split('?')[0],status:fault==='unsigned'?'DELIVERING':'DELIVERED',signedAt:'2026-10-02T22:00:00'}} + else if(path.includes('/fee-pool?'))data=[{customerId:'92',sourceId:'F1',sourceVersion:fault==='wrong-fee-version'?2:1,amount:30,currency:'CNY',sourceType:'OVERSEAS_DELIVERY_FEE'}] else throw Error('unexpected path') - return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'r', traceId: 't' } - } } - return { context, calls, report: { success: false, createdResources: resources, steps: [{ name: '本次车次货物结案及车辆司机释放完成', passed: false }] } } + return {http:200,body:{code:'200',data:structuredClone(data)},requestId:'r',traceId:'t'} + }} + return {context,calls,report:{success:false,createdResources:resources,steps:[{name:'本次车次货物结案及车辆司机释放完成',passed:false}]}} } - test('terminal reconciliation reads actual completion, every dispatch/delivery and exact fee without replaying writes', async () => { const f = terminalFixture() assert.equal(reviewedFullCheckpoint(f.report, 'TERMINAL_READONLY').terminal, true) const result = await verifyFleetTerminalState(f.context) assert.equal(result.terminalReadback.status, 'VERIFIED') - assert.equal(f.calls.length, 7) + assert.equal(f.calls.length, 6) assert.ok(f.calls.every(row => row.method === 'GET')) const invalid = terminalFixture().report invalid.steps.push({ name: '真实业务失败', passed: false }) @@ -805,3 +791,150 @@ for (const fault of ['quantity', 'unsigned', 'wrong-fee-version']) test(`termina await assert.rejects(() => verifyFleetTerminalState(f.context)) assert.equal(f.context.createdResources.fleetAcceptance.terminalReadback.status, 'CHECKING') }) + +function nativeFullSourceFixture() { + const context = { marker: 'RUN', createdResources: { + phase4OverseasPda: { marker: 'RUN', finalStatus: 'COMPLETED', customerId: '11', overseasWarehouseId: '12', palletIds: ['101','102','103'] }, + phase4OverseasDispatch: { marker: 'RUN', status: 'VERIFIED', dispatchNo: 'PARTIAL', customerId: '11', recordVersion: '9007199254740993', + routeLifecycle: { stages: [{ label: '新增同目的地第二托', snapshot: { items: [{ sourceKey: 'PLAN:23', warehousePalletId: '103' }] } }] } } + } } + const original = { warehouseId: '12', data: { dispatchNo: 'PARTIAL', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '9007199254740993', + counts: { pickedPackageCount: 0, shippedPackageCount: 0 } }, items: { items: [ + { sourceKey: 'PLAN:21', warehousePalletId: '101', destination: { key: 'FBA:A' } }, + { sourceKey: 'PLAN:22', warehousePalletId: '102', destination: { key: 'FBA:B' } }] } } + const candidate = { customerId: '11', warehouseId: '12', availablePackages: 2, blockReasons: [], appointmentBatches: [], sourceKey: 'PLAN:23', + sourceVersion: 'a'.repeat(64), destination: { type: 'FBA', key: 'FBA:A' }, selection: { + sourceType: 'INBOUND_PLAN', planPalletId: '23', warehousePalletId: '103', sourceVersion: 'a'.repeat(64) } } + return { context, original, candidate } +} + +test('FULL consumes only the current-run released free duplicate while PARTIAL retains its distinct two destinations', () => { + const f = nativeFullSourceFixture() + assert.equal(selectNativeFleetFullSource(f.context, f.original, [f.candidate]), f.candidate) + for (const mutate of [ + x => x.original.data.recordVersion = '9007199254740992', + x => x.original.data.counts.pickedPackageCount = 1, + x => x.original.items.items[1].destination.key = 'FBA:A', + x => x.candidate.customerId = '99', x => x.candidate.warehouseId = '99', + x => x.candidate.blockReasons.push('已占用'), x => x.candidate.availablePackages = 0, + x => x.context.createdResources.phase4OverseasPda.palletIds = ['101','102'], + x => x.context.createdResources.phase4OverseasDispatch.routeLifecycle.stages = [], + x => x.original.items.items[0].warehousePalletId = '103', + x => x.original.items.items[0].sourceKey = 'PLAN:23' + ]) { const invalid = nativeFullSourceFixture(); mutate(invalid); assert.throws(() => selectNativeFleetFullSource(invalid.context, invalid.original, [invalid.candidate])) } + assert.throws(() => selectNativeFleetFullSource(f.context, f.original, [f.candidate, structuredClone(f.candidate)]), /exactly one/) +}) + +test('native delivery references deduplicate the shared external destination but reject conflicting customer or cargo ownership', () => { + const own = { customerId: '11', deliveryNo: 'D-A', outboundNo: 'OUT-A', groupKey: 'G-A', packageCount: 3 } + const external = { customerId: '22', deliveryNo: 'D-B', outboundNo: 'OUT-B', groupKey: 'G-B', packageCount: 1 } + const detail = { dispatchNo: 'MAIN', documents: { deliveries: [own, external, structuredClone(external)] } } + assert.deepEqual(nativeFleetDeliveries(detail), [own, external]) + for (const changes of [{ customerId: '33' }, { outboundNo: 'FOREIGN' }, { packageCount: 2 }, { groupKey: 'OTHER' }]) { + assert.throws(() => nativeFleetDeliveries({ ...detail, documents: { deliveries: [own, external, { ...external, ...changes }] } })) + } + assert.throws(() => nativeFleetDeliveries({ dispatchNo: 'MAIN', documents: { deliveries: [{ ...own, customerId: null }] } })) + assert.throws(() => nativeFleetDeliveries({ dispatchNo: 'MAIN', documents: { deliveries: [] } })) +}) + +function nativeDepartureFixture(fault = '') { + const refs = [{ customerId: '11', deliveryNo: 'DEL-A', outboundNo: 'OUT-A', packageCount: 2 }, + { customerId: '22', deliveryNo: 'DEL-B', outboundNo: 'OUT-B', packageCount: 1 }] + const before = { dispatchNo: 'MAIN', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993', pickingReconciled: true, + counts: { packageCount: 3, pickedPackageCount: 3, shippedPackageCount: 0 }, documents: { deliveries: refs } } + const task = { ...structuredClone(before), warehouseId: '12', actions: { DEPART: { allowed: true } } } + const items = { dispatchNo: 'MAIN', recordVersion: before.recordVersion, items: [ + { dispatchItemId: '1', scheduledPackages: 2, pickedPackages: 2 }, { dispatchItemId: '2', scheduledPackages: 1, pickedPackages: 1 }] } + if (fault === 'stale-task') task.recordVersion = '9007199254740992' + if (fault === 'wrong-main') task.dispatchNo = 'OTHER' + if (fault === 'not-reconciled') task.pickingReconciled = false + if (fault === 'not-loaded') before.stage = 'LOADING' + if (fault === 'not-arranged') task.actions.DEPART.allowed = false + const command = { dispatchNo: 'MAIN', status: 'ACTIVE', stage: 'DELIVERING', action: 'DEPART', recordVersion: '9007199254740994', replayed: false, + result: { departedAt: '2026-10-19T01:00:00Z', shippedPackages: 3, deliveries: structuredClone(refs) } } + const after = { ...structuredClone(before), stage: 'DELIVERING', recordVersion: command.recordVersion, + counts: { packageCount: 3, pickedPackageCount: 3, shippedPackageCount: 3 } } + let departed = false + const calls = [], saved = [], run = { tripNo: 'TRIP', dispatchNo: 'MAIN' }, trip = { tripNo: 'TRIP', dispatchNo: 'MAIN', warehouseId: '12' } + const context = { async checkpoint() { saved.push(structuredClone(run)) }, async request(method, path, body, token) { + calls.push({ method, path, body: structuredClone(body), token }); let value + if (method === 'POST') { + assert.deepEqual(saved[0].departureRequest, body, 'original current-version command is durable before sending') + assert.equal(body.recordVersion, '9007199254740993') + if (path.startsWith('/api/pda/')) { + assert.equal(token, 'pda'); departed = true; value = structuredClone(command) + if (fault === 'changed-owner') value.result.deliveries[0].customerId = '99' + if (fault === 'changed-quantities') { value.result.deliveries[0].packageCount = 1; value.result.deliveries[1].packageCount = 2 } + } else { + assert.equal(token, undefined); assert.deepEqual(body, calls.find(row => row.method === 'POST').body) + value = { ...structuredClone(command), replayed: fault !== 'replay-miss' } + if (fault === 'replay-version') value.recordVersion = '9007199254740995' + } + } else if (path.startsWith('/api/pda/')) { assert.equal(token, 'pda'); value = task } + else if (path.endsWith('/items')) value = { ...items, recordVersion: departed ? after.recordVersion : before.recordVersion } + else if (path.includes('/delivery-orders/')) { + const ref = refs.find(row => path.includes('/' + row.deliveryNo + '?')) + assert.ok(path.endsWith('customerId=' + ref.customerId)) + value = { order: { ...ref, dispatchNo: 'MAIN', status: 'DELIVERING', version: '3' } } + if (fault === 'foreign-delivery') value.order.customerId = '99' + } else value = departed ? after : before + return { http: 200, body: { code: '200', data: structuredClone(value) }, requestId: 'r', traceId: 't' } + } } + return { context, trip, run, calls, saved } +} + +test('one native current-version PDA DEPART replays exactly through platform and retains every customer delivery version', async () => { + const f = nativeDepartureFixture() + const result = await departNativeFleet(f.context, f.trip, 'pda', f.run) + assert.equal(result.departed, f.run.departure) + assert.deepEqual(f.run.dispatchNos, ['MAIN']) + assert.equal(result.dispatchDetails.length, 1) + assert.deepEqual(f.run.departureDeliveries.map(row => [row.customerId,row.version]), [['11','3'],['22','3']]) + assert.equal(f.calls.filter(row => row.method === 'POST').length, 2) + const before = f.calls.length + await assert.rejects(departNativeFleet(f.context, f.trip, 'pda', f.run), /original command/) + assert.equal(f.calls.length, before, 'no fresh key after an original command exists') +}) + +for (const fault of ['stale-task','wrong-main','not-reconciled','not-loaded','not-arranged','changed-owner','changed-quantities','replay-miss','replay-version','foreign-delivery']) + test(`native departure refuses stale or conflicting actual evidence: ${fault}`, async () => { + const f = nativeDepartureFixture(fault) + await assert.rejects(departNativeFleet(f.context, f.trip, 'pda', f.run)) + if (['stale-task','wrong-main','not-reconciled','not-loaded','not-arranged'].includes(fault)) { + assert.equal(f.calls.filter(row => row.method === 'POST').length, 0) + assert.equal(f.saved.length, 0) + } else assert.ok(f.run.departureRequest?.idempotencyKey, 'sent requests retain their original key') + }) + +test('physical loading requires current-run actual per-field evidence and never fills pallet spaces from planned capacity', async () => { + const calls = [], trip = { dispatchNo: 'MAIN', stops: [{ destinationKey: 'ORIGIN:12' }, { destinationKey: 'FBA:A' }] } + const preview = { dispatchNo: 'MAIN', status: 'ACTIVE', pallets: [{ warehousePalletId: '101', palletNo: 'PALLET', palletSpaces: null, weightKg: null, volumeCbm: null }] } + const context = { marker: 'RUN', createdResources: { phase4OverseasDispatch: {} }, async request(method, path) { + assert.equal(method, 'GET'); calls.push(path) + return { http: 200, body: { code: '200', data: structuredClone(preview) }, requestId: 'r', traceId: 't' } + } } + await assert.rejects(currentFleetMeasurements(context, trip), /Missing current-run actual palletSpaces/) + const actual = { warehousePalletId: '101', palletSpaces: 2, weightKg: 121, volumeCbm: 1.37, deliveryIndex: 1, + evidence: ['palletSpaces','weightKg','volumeCbm'].map(field => ({ field, value: { palletSpaces: 2, weightKg: 121, volumeCbm: 1.37 }[field], + actual: true, sourceType: 'PDA_MEASUREMENT', sourceBusinessNo: 'CURRENT-INCOMING', observedAt: '2026-10-19T01:00:00Z' })) } + context.createdResources.phase4OverseasDispatch.loadingMeasurements = { environment: 'TEST', marker: 'RUN', dispatchNo: 'MAIN', pallets: [actual] } + assert.deepEqual((await currentFleetMeasurements(context, trip)).pallets, [actual]) + for (const mutation of [receipt => receipt.marker = 'OLD', receipt => receipt.dispatchNo = 'OTHER', + receipt => receipt.pallets[0].evidence[0].actual = false, receipt => receipt.pallets[0].evidence[0].value = 1, + receipt => receipt.pallets.push({ ...receipt.pallets[0], warehousePalletId: '999' })]) { + const receipt = structuredClone(context.createdResources.phase4OverseasDispatch.loadingMeasurements) + mutation(receipt) + await assert.rejects(currentFleetMeasurements({ ...context, createdResources: { phase4OverseasDispatch: { loadingMeasurements: receipt } } }, trip)) + } + assert.equal(calls.length, 7) +}) + +test('native checkpoint rejects retired flat reports, multi-main binding and globally unknown writes', () => { + for (const mutation of [report => delete report.createdResources.fleetAcceptance.protocol, + report => report.createdResources.fleetAcceptance.dispatchNos.push('OTHER'), + report => report.createdResources.fleetTrips[0].dispatchNo = 'OTHER', + report => report.createdResources.fleetWriteBoundary = { status: 'STOPPED', outcome: 'UNKNOWN_RESULT' }]) { + const report = terminalFixture().report; mutation(report) + assert.throws(() => reviewedFullCheckpoint(report, 'TERMINAL_READONLY'), /native single-dispatch reports/) + } +}) diff --git a/scripts/fleet-trip-rollback-test-env.mjs b/scripts/fleet-trip-rollback-test-env.mjs index 51dce2e60..beed04da1 100644 --- a/scripts/fleet-trip-rollback-test-env.mjs +++ b/scripts/fleet-trip-rollback-test-env.mjs @@ -6,137 +6,405 @@ import { pathToFileURL } from 'node:url' import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' -const demand=(v,message)=>{if(!v)throw Error(message)} -const literal=v=>`CONVERT(0x${Buffer.from(v).toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` -const encoded=v=>'['+Buffer.from(v).toString('base64').match(/.{1,4000}/g).map(x=>`'${x}'`).join(',')+"].join('')" -const id=v=>String(v??'') -const rr=['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;','START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] -const pause=ms=>new Promise(done=>setTimeout(done,ms)) -const canonical=v=>JSON.stringify(sortFields(v)) -function sortFields(v){return Array.isArray(v)?v.map(sortFields):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sortFields(v[k])])):v} -export function validateRollbackScope(s) { - demand(s&&/^[A-Za-z0-9_-]{1,64}$/.test(s.marker||'')&&/^[A-Za-z0-9_-]{1,64}$/.test(s.tripNo||''),'ROLLBACK_SCOPE_REQUIRED') - for(const key of ['firstCustomerId','secondCustomerId','firstDispatchId','secondDispatchId','firstOutboundId','secondPalletId']) - demand(typeof s[key]==='string'&&/^[1-9][0-9]{0,19}$/.test(s[key]),'ROLLBACK_IDENTIFIER_INVALID') - demand(s.firstCustomerId!==s.secondCustomerId&&s.firstDispatchId!==s.secondDispatchId&&/^sha256:[a-f0-9]{64}$/.test(s.expectedImage||''),'ROLLBACK_DISTINCT_CUSTOMERS_AND_RUNTIME_REQUIRED') - demand(/^[A-Za-z0-9_-]{1,64}$/.test(s.firstDispatchNo||''),'ROLLBACK_DISPATCH_NUMBER_INVALID') - return s +const demand = (value, message) => { if (!value) throw Error(message) } +const identifier = value => typeof value === 'string' && /^[1-9][0-9]{0,19}$/.test(value) +const businessNo = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value) +const literal = value => `CONVERT(0x${Buffer.from(value).toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` +const encoded = value => '[' + Buffer.from(value).toString('base64').match(/.{1,4000}/g).map(x => `'${x}'`).join(',') + "].join('')" +const pause = ms => new Promise(done => setTimeout(done, ms)) +const canonical = value => JSON.stringify(sortFields(value)) +const sha = value => createHash('sha256').update(canonical(value)).digest('hex') +function sortFields(value) { + return Array.isArray(value) ? value.map(sortFields) : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, sortFields(value[key])])) : value } -export function rollbackSql(scope,mode) { - const s=validateRollbackScope(scope),second=`customer_id=${literal(s.secondCustomerId)} AND dispatch_order_id=${s.secondDispatchId} AND warehouse_pallet_id=${s.secondPalletId} AND status='ACTIVE'` - if(mode==='LEASE')return `SET SESSION innodb_lock_wait_timeout=5;START TRANSACTION;SELECT JSON_OBJECT('lockId',CAST(id AS CHAR),'customerId',customer_id,'connectionId',CONNECTION_ID()) FROM ll_dispatch_pallet_lock WHERE ${second} FOR UPDATE;SELECT JSON_OBJECT('marker','ROLLBACK_${s.marker}','elapsed',SLEEP(65));ROLLBACK;SELECT JSON_OBJECT('released',JSON_EXTRACT('true','$'));` - const lease=`SELECT JSON_OBJECT('collection','lease','connectionId',t.trx_mysql_thread_id,'lockId',l.LOCK_DATA) FROM performance_schema.data_locks l JOIN information_schema.innodb_trx t ON t.trx_id=l.ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_dispatch_pallet_lock' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_STATUS='GRANTED' AND l.LOCK_DATA IN (SELECT CAST(id AS CHAR) FROM ll_dispatch_pallet_lock WHERE ${second}) AND t.trx_query LIKE ${literal('%ROLLBACK_'+s.marker+'%')};` - const waits=`SELECT JSON_OBJECT('collection','wait','requester',r.trx_mysql_thread_id,'blocker',b.trx_mysql_thread_id,'lockId',l.LOCK_DATA) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON r.trx_id=w.REQUESTING_ENGINE_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id=w.BLOCKING_ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_dispatch_pallet_lock' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_DATA IN (SELECT CAST(id AS CHAR) FROM ll_dispatch_pallet_lock WHERE ${second});` - if(mode==='LOCKS')return [...rr,lease,waits,'COMMIT;'].join('\n') - if(mode==='OBSERVE')return ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;','START TRANSACTION READ ONLY;',lease,waits, - `SELECT JSON_OBJECT('collection','firstDispatch','id',CAST(id AS CHAR),'status',status,'shipped',shipped_package_count) FROM ll_dispatch_order WHERE id=${s.firstDispatchId} AND customer_id=${literal(s.firstCustomerId)};`, - `SELECT JSON_OBJECT('collection','firstOutbound','id',CAST(id AS CHAR),'status',status) FROM ll_outbound_order WHERE id=${s.firstOutboundId} AND customer_id=${literal(s.firstCustomerId)};`, - `SELECT JSON_OBJECT('collection','firstReplay','count',COUNT(*)) FROM ll_dispatch_pda_operation WHERE customer_id=${literal(s.firstCustomerId)} AND operation_type='DEPART' AND idempotency_key=${literal(s.tripNo+':'+s.firstDispatchNo)};`,'COMMIT;'].join('\n') - if(mode==='RELEASED')return [...rr,lease,waits,'COMMIT;'].join('\n') +const rr = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] +const databaseRuntime = "SELECT JSON_OBJECT('collection','databaseRuntime','serverUuid',@@server_uuid,'databaseName',DATABASE(),'performanceSchema',@@performance_schema);" +// Every column is obtained from the actual schema; no partial projection can masquerade as a full rollback baseline. +const tables = ['ll_dispatch_order', 'll_dispatch_history', 'll_dispatch_item', 'll_dispatch_command', 'll_dispatch_pallet_lock', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard', 'll_dispatch_item_source', + 'll_fleet_trip', 'll_fleet_trip_dispatch', 'll_fleet_trip_history', 'll_fleet_pool_order_link', 'll_outbound_order', 'll_delivery_order', + 'll_order', 'll_loading_order_status_history', 'll_warehouse_transfer_order', 'll_dispatch_pda_task', 'll_dispatch_pda_pick', + 'll_dispatch_pda_pick_order', 'll_inventory_balance', 'll_inventory_position', 'll_inventory_ledger'] +const columnsSql = `SELECT JSON_OBJECT('collection','column','table',TABLE_NAME,'column',COLUMN_NAME,'ordinal',ORDINAL_POSITION,'type',COLUMN_TYPE,'key',COLUMN_KEY) FROM information_schema.columns WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (${tables.map(literal).join(',')}) ORDER BY TABLE_NAME,ORDINAL_POSITION;` +const projection = (collection, table, fields, where) => `SELECT JSON_OBJECT('collection','${collection}',${Object.entries(fields).map(([key, expression]) => `'${key}',CAST(${expression} AS CHAR)`).join(',')}) FROM ${table} WHERE ${where};` +const one = (rows, collection) => { const found = rows.filter(row => row.collection === collection); demand(found.length === 1, `EXACT_${collection.toUpperCase()}_REQUIRED`); return found[0] } + +export function validateRollbackRequest(scope) { + demand(scope && businessNo(scope.marker) && businessNo(scope.tripNo) && businessNo(scope.dispatchNo) + && businessNo(scope.commandKey) && identifier(scope.commandVersion) && identifier(scope.warehouseId) + && /^sha256:[a-f0-9]{64}$/.test(scope.expectedImage || ''), 'NATIVE_ROLLBACK_REQUEST_REQUIRED') + demand(scope.members?.length === 2 && new Set(scope.members.map(m => m.customerId)).size === 2, 'TWO_DISTINCT_CUSTOMERS_REQUIRED') + for (const member of scope.members) { + demand(['customerId', 'dispatchItemId', 'palletId', 'positionId', 'balanceId'].every(field => identifier(member[field])) + && ['incomingNo', 'outboundNo', 'deliveryNo'].every(field => businessNo(member[field])), 'OWNED_MEMBER_IDENTITIES_REQUIRED') + } + for (const field of ['incomingNo', 'dispatchItemId', 'palletId', 'positionId', 'balanceId', 'outboundNo', 'deliveryNo']) + demand(new Set(scope.members.map(m => m[field])).size === 2, 'MEMBER_IDENTITY_REUSED') + return scope +} + +function resolutionSql(scope) { + validateRollbackRequest(scope) + const root = `(SELECT id FROM ll_dispatch_order WHERE dispatch_no=${literal(scope.dispatchNo)})` + const trip = `(SELECT id FROM ll_fleet_trip WHERE trip_no=${literal(scope.tripNo)})` + const items = `dispatch_order_id=${root}` + const sourceOrders = `(SELECT order_id FROM ll_dispatch_item WHERE ${items})` + return [...rr, databaseRuntime, columnsSql, + projection('root', 'll_dispatch_order', { id: 'id', dispatchNo: 'dispatch_no', warehouseId: 'overseas_warehouse_id', version: 'record_version', status: 'status', stage: 'stage' }, `dispatch_no=${literal(scope.dispatchNo)}`), + projection('trip', 'll_fleet_trip', { id: 'id', tripNo: 'trip_no', warehouseId: 'warehouse_id', status: 'status' }, `trip_no=${literal(scope.tripNo)}`), + projection('binding', 'll_fleet_trip_dispatch', { dispatchId: 'dispatch_id', dispatchNo: 'dispatch_no', tripId: 'trip_id', tripNo: 'trip_no' }, `trip_id=${trip} OR dispatch_id=${root}`), + projection('item', 'll_dispatch_item', { id: 'id', dispatchId: 'dispatch_order_id', customerId: 'customer_id', palletId: 'warehouse_pallet_id', orderId: 'order_id', sourceType: 'source_business_type', sourceNo: 'source_business_no', incomingNo: 'inbound_code', packages: 'package_count' }, items), + projection('transfer', 'll_warehouse_transfer_order', { id: 'id', customerId: 'customer_id', incomingNo: 'transfer_no', orderId: 'lastleg_order_id', warehouseId: 'operating_warehouse_id', needRelabel: 'need_relabel', relabelNo: 'relabel_no', outboundAt: 'outbound_at' }, `lastleg_order_id IN ${sourceOrders}`), + projection('outbound', 'll_outbound_order', { id: 'id', customerId: 'customer_id', dispatchId: 'dispatch_order_id', outboundNo: 'outbound_no', status: 'status' }, items), + projection('delivery', 'll_delivery_order', { id: 'id', customerId: 'customer_id', dispatchId: 'dispatch_order_id', deliveryNo: 'delivery_no', outboundNo: 'outbound_no', status: 'status' }, items), + projection('task', 'll_dispatch_pda_task', { id: 'id', dispatchId: 'dispatch_order_id', warehouseId: 'warehouse_id' }, items), + 'COMMIT;'].join('\n') +} + +export function resolveRollbackScope(request, rows) { + validateRollbackRequest(request) + const root = one(rows, 'root'), trip = one(rows, 'trip'), binding = one(rows, 'binding'), task = one(rows, 'task') + demand(identifier(root.id) && identifier(trip.id) && identifier(task.id) && root.dispatchNo === request.dispatchNo + && root.warehouseId === request.warehouseId && root.version === request.commandVersion && root.status === 'ACTIVE' && root.stage === 'AFTER_LOADING' + && trip.tripNo === request.tripNo && trip.warehouseId === request.warehouseId && trip.status === 'PLANNED' + && binding.dispatchId === root.id && binding.tripId === trip.id && binding.dispatchNo === request.dispatchNo && binding.tripNo === request.tripNo + && task.dispatchId === root.id && task.warehouseId === request.warehouseId, 'ONE_NATIVE_ROOT_AND_FLEET_REQUIRED') + for (const collection of ['item', 'transfer', 'outbound', 'delivery']) demand(rows.filter(r => r.collection === collection).length === 2, `TWO_${collection.toUpperCase()}_ROWS_REQUIRED`) + const members = request.members.map(member => { + const own = collection => one(rows.filter(r => r.customerId === member.customerId), collection) + const item = own('item'), transfer = own('transfer'), outbound = own('outbound'), delivery = own('delivery') + demand(item.id === member.dispatchItemId && item.dispatchId === root.id && item.palletId === member.palletId && identifier(item.orderId) + // Native ordinary cargo leaves source_business_type/no NULL; the actual transfer/order/receipt links prove its origin. + && item.sourceType === null && item.sourceNo === null && item.incomingNo === member.incomingNo && item.packages === '1' + && identifier(transfer.id) && transfer.orderId === item.orderId && transfer.incomingNo === member.incomingNo + && transfer.warehouseId === request.warehouseId && transfer.needRelabel === '0' && transfer.relabelNo === null && transfer.outboundAt === null + && identifier(outbound.id) && outbound.dispatchId === root.id && outbound.outboundNo === member.outboundNo && outbound.status === 'WAIT_OUTBOUND' + && identifier(delivery.id) && delivery.dispatchId === root.id && delivery.deliveryNo === member.deliveryNo + && delivery.outboundNo === member.outboundNo && delivery.status === 'WAIT_DELIVERY', 'REAL_ORDINARY_TRANSFER_AND_CHILD_LINKS_REQUIRED') + return { ...member, orderId: item.orderId, transferId: transfer.id, outboundId: outbound.id, deliveryId: delivery.id } + }).sort((a, b) => a.customerId < b.customerId ? -1 : a.customerId > b.customerId ? 1 : 0) // Java String.compareTo for decimal customer IDs; never numeric/locale order. + const scope = { ...request, dispatchId: root.id, tripId: trip.id, taskId: task.id, members, + columns: rows.filter(row => row.collection === 'column') } + return validateRollbackScope(scope) +} + +export function validateRollbackScope(scope) { + validateRollbackRequest(scope) + demand(['dispatchId', 'tripId', 'taskId'].every(field => identifier(scope[field])) + && scope.members.every(member => ['orderId', 'transferId', 'outboundId', 'deliveryId'].every(field => identifier(member[field]))) + && scope.members[0].customerId < scope.members[1].customerId, 'RESOLVED_SHARED_ROOT_AND_JAVA_CUSTOMER_ORDER_REQUIRED') + for (const field of ['orderId', 'transferId', 'outboundId', 'deliveryId']) demand(new Set(scope.members.map(m => m[field])).size === 2, 'RESOLVED_MEMBER_ID_REUSED') + if (scope.observedTransactions) demand(scope.observedTransactions.length === 2 && scope.observedTransactions.every(identifier) + && new Set(scope.observedTransactions).size === 2, 'EXACT_OBSERVED_TRANSACTION_IDS_REQUIRED') + demand(Array.isArray(scope.columns) && scope.columns.every(c => c.collection === 'column' && tables.includes(c.table) + && /^[a-z][a-z0-9_]*$/.test(c.column) && Number.isSafeInteger(c.ordinal) && c.ordinal > 0 && typeof c.type === 'string'), 'ACTUAL_SCHEMA_COLUMNS_REQUIRED') + for (const table of tables) { + const columns = scope.columns.filter(c => c.table === table) + demand(columns.length > 0 && columns.some(c => c.key === 'PRI') && new Set(columns.map(c => c.column)).size === columns.length + && columns.every((c, i) => c.ordinal === i + 1), 'COMPLETE_ACTUAL_SCHEMA_REQUIRED') + } + const fields = table => new Set(scope.columns.filter(column => column.table === table).map(column => column.column)) + const guard = scope.columns.filter(column => column.table === 'll_physical_pallet_guard') + demand(guard.length === 2 && guard.some(column => column.column === 'warehouse_pallet_id' && column.key === 'PRI' && column.type.toLowerCase() === 'bigint') + && guard.some(column => column.column === 'created_at' && column.key !== 'PRI' && column.type.toLowerCase() === 'datetime(3)'), 'PERMANENT_GLOBAL_PHYSICAL_PALLET_GUARD_SCHEMA_REQUIRED') + demand(fields('ll_dispatch_physical_pallet_lock').has('active_warehouse_pallet_id') + && !fields('ll_dispatch_physical_pallet_lock').has('customer_id') + && fields('ll_dispatch_pallet_lock').has('physical_lock_id') && !fields('ll_dispatch_pallet_lock').has('active_warehouse_pallet_id') + && fields('ll_dispatch_item_source').has('dispatch_item_id') && fields('ll_dispatch_pda_pick_order').has('dispatch_item_source_id'), + 'CURRENT_PHYSICAL_AND_LOGICAL_SOURCE_SCHEMA_REQUIRED') + return scope +} + +function snapshotSql(scope) { + const source = scope.members.map(m => `(customer_id=${literal(m.customerId)} AND id=${m.orderId})`).join(' OR ') + const histories = scope.members.map(m => `(customer_id=${literal(m.customerId)} AND order_id=${m.orderId})`).join(' OR ') + const customer = `customer_id IN (${scope.members.map(m => literal(m.customerId)).join(',')})` + const stock = `${customer} AND overseas_warehouse_id=${literal(scope.warehouseId)}` + const predicates = { + ll_dispatch_order: `id=${scope.dispatchId}`, ll_dispatch_history: `dispatch_id=${scope.dispatchId}`, + ll_dispatch_item: `dispatch_order_id=${scope.dispatchId}`, ll_dispatch_command: `dispatch_id=${scope.dispatchId}`, + ll_dispatch_pallet_lock: `dispatch_order_id=${scope.dispatchId}`, + ll_dispatch_physical_pallet_lock: `dispatch_order_id=${scope.dispatchId} OR warehouse_pallet_id IN (${scope.members.map(member => member.palletId).join(',')})`, + ll_physical_pallet_guard: `warehouse_pallet_id IN (${scope.members.map(member => member.palletId).join(',')})`, + ll_dispatch_item_source: `dispatch_order_id=${scope.dispatchId} OR dispatch_item_id IN (${scope.members.map(member => member.dispatchItemId).join(',')})`, + ll_fleet_trip: `id=${scope.tripId}`, + ll_fleet_trip_dispatch: `trip_id=${scope.tripId} OR dispatch_id=${scope.dispatchId}`, ll_fleet_trip_history: `trip_id=${scope.tripId}`, + ll_fleet_pool_order_link: `dispatch_no=${literal(scope.dispatchNo)}`, ll_outbound_order: `dispatch_order_id=${scope.dispatchId}`, + ll_delivery_order: `dispatch_order_id=${scope.dispatchId}`, ll_order: source, ll_loading_order_status_history: histories, + ll_warehouse_transfer_order: `lastleg_order_id IN (${scope.members.map(m => m.orderId).join(',')})`, + ll_dispatch_pda_task: `dispatch_order_id=${scope.dispatchId}`, ll_dispatch_pda_pick: `task_id=${scope.taskId}`, + ll_dispatch_pda_pick_order: `pick_id IN (SELECT id FROM ll_dispatch_pda_pick WHERE task_id=${scope.taskId})`, + ll_inventory_balance: stock, ll_inventory_position: stock, ll_inventory_ledger: stock, + } + return [columnsSql, ...tables.flatMap(table => { + const columns = scope.columns.filter(c => c.table === table) + const json = columns.map(c => `'${c.column}',CAST(\`${c.column}\` AS CHAR CHARACTER SET utf8mb4)`).join(',') + return [`SELECT JSON_OBJECT('collection','coverage','table','${table}','count',COUNT(*)) FROM ${table} WHERE ${predicates[table]};`, + `SELECT JSON_OBJECT('collection','row','table','${table}','row',JSON_OBJECT(${json})) FROM ${table} WHERE ${predicates[table]};`] + })].join('\n') +} + +function lockSql(scope) { + const target = scope.members[1] + const held = `l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_warehouse_transfer_order' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_DATA='${target.transferId}'` + const lease = `SELECT JSON_OBJECT('collection','lease','connectionId',CAST(t.trx_mysql_thread_id AS CHAR),'transactionId',CAST(t.trx_id AS CHAR),'lockId',l.LOCK_DATA,'mode',l.LOCK_MODE,'ageSeconds',TIMESTAMPDIFF(SECOND,t.trx_started,NOW())) FROM performance_schema.data_locks l JOIN information_schema.innodb_trx t ON t.trx_id=l.ENGINE_TRANSACTION_ID WHERE ${held} AND l.LOCK_STATUS='GRANTED' AND t.trx_query LIKE ${literal('%ROLLBACK_' + scope.marker + '%')};` + const waits = `SELECT JSON_OBJECT('collection','wait','requester',CAST(r.trx_mysql_thread_id AS CHAR),'blocker',CAST(b.trx_mysql_thread_id AS CHAR),'requestingTransactionId',CAST(r.trx_id AS CHAR),'blockingTransactionId',CAST(b.trx_id AS CHAR),'lockId',l.LOCK_DATA,'blockingLockId',bl.LOCK_DATA,'objectName',l.OBJECT_NAME,'indexName',l.INDEX_NAME,'requestStatus',l.LOCK_STATUS,'blockingStatus',bl.LOCK_STATUS,'statement',r.trx_query,'sessionLockWaitSeconds',v.VARIABLE_VALUE,'ownsRoot',EXISTS(SELECT 1 FROM performance_schema.data_locks dl WHERE dl.ENGINE_TRANSACTION_ID=r.trx_id AND dl.OBJECT_SCHEMA=DATABASE() AND dl.OBJECT_NAME='ll_dispatch_order' AND dl.INDEX_NAME='PRIMARY' AND dl.LOCK_DATA='${scope.dispatchId}' AND dl.LOCK_STATUS='GRANTED' AND dl.LOCK_MODE LIKE 'X%')) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID AND l.ENGINE=w.ENGINE JOIN performance_schema.data_locks bl ON bl.ENGINE_LOCK_ID=w.BLOCKING_ENGINE_LOCK_ID AND bl.ENGINE=w.ENGINE JOIN information_schema.innodb_trx r ON r.trx_id=w.REQUESTING_ENGINE_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id=w.BLOCKING_ENGINE_TRANSACTION_ID JOIN performance_schema.threads th ON th.PROCESSLIST_ID=r.trx_mysql_thread_id JOIN performance_schema.variables_by_thread v ON v.THREAD_ID=th.THREAD_ID AND v.VARIABLE_NAME='innodb_lock_wait_timeout' WHERE ${held} AND bl.OBJECT_SCHEMA=DATABASE() AND bl.OBJECT_NAME=l.OBJECT_NAME AND bl.INDEX_NAME='PRIMARY' AND bl.LOCK_DATA=l.LOCK_DATA AND b.trx_query LIKE ${literal('%ROLLBACK_' + scope.marker + '%')};` + return [lease, waits] +} + +export function rollbackSql(scope, mode) { + if (mode === 'RESOLVE') return resolutionSql(scope) + validateRollbackScope(scope) + const second = scope.members[1] + if (mode === 'LEASE') return `SET NAMES utf8mb4 COLLATE utf8mb4_bin;${databaseRuntime}SET SESSION innodb_lock_wait_timeout=5;START TRANSACTION;SELECT JSON_OBJECT('collection','held','lockId',CAST(id AS CHAR),'customerId',customer_id,'connectionId',CAST(CONNECTION_ID() AS CHAR)) FROM ll_warehouse_transfer_order WHERE id=${second.transferId} AND customer_id=${literal(second.customerId)} AND lastleg_order_id=${second.orderId} AND transfer_no=${literal(second.incomingNo)} AND need_relabel=0 AND relabel_no IS NULL AND outbound_at IS NULL FOR UPDATE;SELECT JSON_OBJECT('marker','ROLLBACK_${scope.marker}','elapsed',SLEEP(65));ROLLBACK;SELECT JSON_OBJECT('released',JSON_EXTRACT('true','$'));` + if (mode === 'LOCKS' || mode === 'RELEASED') return [...rr, databaseRuntime, ...lockSql(scope), + ...(mode === 'RELEASED' ? [(() => { + demand(scope.observedTransactions, 'OBSERVED_TRANSACTIONS_REQUIRED_FOR_RELEASE_PROOF') + return `SELECT JSON_OBJECT('collection','remainingTransaction','transactionId',CAST(trx_id AS CHAR)) FROM information_schema.innodb_trx WHERE trx_id IN (${scope.observedTransactions.map(literal).join(',')});` + })()] : []), 'COMMIT;'].join('\n') + if (['BASELINE', 'RESTORED', 'OBSERVE'].includes(mode)) return [ + ...(mode === 'OBSERVE' ? ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;', 'START TRANSACTION READ ONLY;'] : rr), + databaseRuntime, ...(mode === 'OBSERVE' ? lockSql(scope) : []), snapshotSql(scope), 'COMMIT;'].join('\n') throw Error('ROLLBACK_MODE_INVALID') } -export function buildRollbackRemoteScript(scope,mode,agent) { - demand(['RUNTIME','LEASE','LOCKS','OBSERVE','RELEASED'].includes(mode)&&/^[A-Za-z0-9_.-]{1,128}$/.test(agent||''),'ROLLBACK_REMOTE_MODE_REQUIRED') - const sql=mode==='RUNTIME'?"SELECT JSON_OBJECT('lockWaitSeconds',@@GLOBAL.innodb_lock_wait_timeout);":rollbackSql(scope,mode) - const imageGuard=mode==='RUNTIME'?'':` or c['Image']!='${scope.expectedImage}'` - const python=`import json,os,subprocess,urllib.parse,re + +export function buildRollbackRemoteScript(scope, mode, agent) { + demand(['RUNTIME', 'RESOLVE', 'LEASE', 'LOCKS', 'OBSERVE', 'BASELINE', 'RESTORED', 'RELEASED'].includes(mode) + && /^[A-Za-z0-9_.-]{1,128}$/.test(agent || ''), 'ROLLBACK_REMOTE_MODE_REQUIRED') + const sql = mode === 'RUNTIME' ? [databaseRuntime, + ...['data_locks', 'data_lock_waits', 'threads', 'variables_by_thread'].map(table => `SELECT JSON_OBJECT('collection','privilege','table','${table}','count',COUNT(*)) FROM performance_schema.${table} WHERE 1=0;`), + "SELECT JSON_OBJECT('collection','privilege','table','innodb_trx','count',COUNT(*)) FROM information_schema.innodb_trx WHERE 1=0;"].join('\n') : rollbackSql(scope, mode) + const imageGuard = mode === 'RUNTIME' ? '' : ` or c['Image']!='${scope.expectedImage}'` + const python = `import json,os,subprocess,urllib.parse,re try: c=json.loads(subprocess.check_output(['docker','inspect','firstleg-test-lastleg'],text=True,stderr=subprocess.DEVNULL))[0] e=dict(x.split('=',1) for x in c['Config']['Env']) if c['Name']!='/firstleg-test-lastleg' or c['Config'].get('Labels',{}).get('com.docker.compose.project')!='firstleg-test' or e.get('DEPLOY_ENV')!='test' or c['State'].get('Health',{}).get('Status')!='healthy'${imageGuard}:raise RuntimeError('TEST_RUNTIME_CHANGED') u=urllib.parse.urlsplit(e['LASTLEG_DB_URL'].removeprefix('jdbc:'));host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname - q=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['LASTLEG_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=${JSON.stringify(sql)},capture_output=True,text=True,env=dict(os.environ,MYSQL_PWD=e['LASTLEG_DB_PASSWORD']),timeout=${mode==='LEASE'?75:25}) + q=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['LASTLEG_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=${JSON.stringify(sql)},capture_output=True,text=True,env=dict(os.environ,MYSQL_PWD=e['LASTLEG_DB_PASSWORD']),timeout=${mode === 'LEASE' ? 75 : 25}) if q.returncode: code=re.search(r'ERROR ([0-9]{3,5})',q.stderr);print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) else:print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':True,'imageId':c['Image'],'rows':[json.loads(x) for x in q.stdout.splitlines() if x.strip()]})) except Exception as error: print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':False,'error':str(error) if re.fullmatch('[A-Z_]{1,64}',str(error)) else 'REMOTE_ROLLBACK_FAILED'})) ` - const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${encoded(python)}),'UTF-8')]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + const groovy = `def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${encoded(python)}),'UTF-8')]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null||c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${encoded(groovy)}),'UTF-8'),c.channel))` } -export function assertMidTransactionObservation(rows) { - const one=name=>rows.filter(x=>x.collection===name) - const lease=one('lease'),wait=one('wait'),dispatch=one('firstDispatch'),outbound=one('firstOutbound'),replay=one('firstReplay') - demand(lease.length===1&&wait.length===1&&wait[0].blocker===lease[0].connectionId&&wait[0].lockId===lease[0].lockId&&wait[0].requester!==wait[0].blocker,'OWNED_LATE_ROW_WAIT_NOT_PROVED') - demand(dispatch.length===1&&dispatch[0].status==='DEPARTED'&&dispatch[0].shipped===1&&outbound.length===1&&outbound[0].status==='OUTBOUNDED'&&replay.length===1&&replay[0].count===1,'FIRST_CUSTOMER_UNCOMMITTED_WRITE_NOT_PROVED') - return {leaseConnectionId:lease[0].connectionId,requestingConnectionId:wait[0].requester,lockId:lease[0].lockId,dirtyObservationOnly:true} -} -export function requestRollbackDeparture(context,path,body,token) { - // The normal JSON request stops at 30 seconds. This deliberate 50-second - // database timeout needs the existing 60-second, single-attempt request path. - return context.requestWithBody('POST',path,body,token) -} -export function assertDatabaseLockConflict(response) { - demand(response?.http===200&&String(response.body?.code)==='A0409' - &&response.body?.message==='资源正在处理中,请稍后重试' - &&response.requestId&&response.traceId,'EXPECTED_DATABASE_LOCK_CONFLICT_REQUIRED') -} -async function remote(env,scope,mode) { - const script=buildRollbackRemoteScript(scope,mode,env.FBA_CORE_JENKINS_AGENT) - const response=await fetch(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(mode==='LEASE'?95000:45000),headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})}) - demand(response.ok,'ROLLBACK_EVIDENCE_HTTP_FAILED') - const lines=(await response.text()).split(/\r?\n/).filter(x=>x.startsWith('TRIP_ROLLBACK_JSON='));demand(lines.length===1,'ROLLBACK_REMOTE_RESULT_AMBIGUOUS') - const result=JSON.parse(lines[0].slice('TRIP_ROLLBACK_JSON='.length));demand(result.ok===true,/^[A-Z_0-9]+$/.test(result.error||'')?result.error:'ROLLBACK_REMOTE_FAILED') + +export function fullRollbackSnapshot(rows, scope) { + validateRollbackScope(scope) + demand(canonical(rows.filter(r => r.collection === 'column')) === canonical(scope.columns), 'ACTUAL_SCHEMA_CHANGED') + const result = {} + for (const table of tables) { + const coverage = one(rows.filter(r => r.table === table), 'coverage') + const values = rows.filter(r => r.collection === 'row' && r.table === table).map(r => r.row) + const columns = scope.columns.filter(c => c.table === table).map(c => c.column).sort() + demand(Number.isSafeInteger(coverage.count) && coverage.count === values.length + && values.every(r => canonical(Object.keys(r).sort()) === canonical(columns) && Object.values(r).every(v => v === null || typeof v === 'string')) + && new Set(values.map(canonical)).size === values.length, 'COMPLETE_FULL_FIELD_SNAPSHOT_REQUIRED') + result[table] = values.sort((a, b) => canonical(a) < canonical(b) ? -1 : canonical(a) > canonical(b) ? 1 : 0) + } return result } -export function createDepartureRollbackVerifier(env,config,runtime) { - return async({context,fixture,ready,departPath,departBody,snapshot})=>{ - const output=resolve('reports',`fleet-cross-${config.runId}`,'rollback.json'),fd=fs.openSync(output,'wx',0o600) - const report={verified:false,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK',startedAt:new Date().toISOString(),samples:[],command:{path:departPath,body:departBody},runtime} - const save=()=>{fs.ftruncateSync(fd,0);fs.writeSync(fd,JSON.stringify(report,null,2),0,'utf8');fs.fsyncSync(fd)} - let leasePromise,pending +const exactRow = (snapshot, table, id) => { + const found = snapshot[table].filter(row => row.id === id) + demand(found.length === 1, `EXACT_OWNED_ROW_REQUIRED_${table}`); return found[0] +} +const immutableTables = ['ll_physical_pallet_guard', 'll_dispatch_physical_pallet_lock', 'll_dispatch_item_source', 'll_dispatch_pda_task', 'll_dispatch_pda_pick', 'll_dispatch_pda_pick_order', 'll_inventory_balance', 'll_inventory_position', 'll_inventory_ledger'] +export function assertRollbackBaseline(snapshot, scope) { + const root = exactRow(snapshot, 'll_dispatch_order', scope.dispatchId), trip = exactRow(snapshot, 'll_fleet_trip', scope.tripId) + demand(snapshot.ll_dispatch_order.length === 1 && snapshot.ll_fleet_trip.length === 1 && snapshot.ll_fleet_trip_dispatch.length === 1 + && root.status === 'ACTIVE' && root.stage === 'AFTER_LOADING' && root.record_version === scope.commandVersion && root.departed_at === null + && root.shipped_package_count === '0' && trip.status === 'PLANNED' && trip.departed_at === null, 'READY_NATIVE_BASELINE_REQUIRED') + demand(snapshot.ll_fleet_pool_order_link.length === 0, 'ORDINARY_TRANSFER_FIXTURE_CANNOT_HAVE_POOL_LINKS') + demand(snapshot.ll_dispatch_item_source.length === 0, 'ORDINARY_TRANSFER_FIXTURE_CANNOT_HAVE_RELABEL_CONTRIBUTIONS') + for (const table of ['ll_dispatch_item', 'll_outbound_order', 'll_delivery_order', 'll_order', 'll_warehouse_transfer_order', 'll_dispatch_pallet_lock', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard', 'll_dispatch_pda_pick', 'll_dispatch_pda_pick_order']) + demand(snapshot[table].length === 2, 'EXACT_TWO_CUSTOMER_BASELINE_REQUIRED') + demand(snapshot.ll_dispatch_pda_task.length === 1 && snapshot.ll_dispatch_pda_task[0].id === scope.taskId, 'ORIGINAL_TASK_REQUIRED') + demand(!snapshot.ll_dispatch_command.some(r => r.action === 'DEPART' || r.idempotency_key === scope.commandKey) + && !snapshot.ll_dispatch_history.some(r => r.action === 'DEPART') && !snapshot.ll_fleet_trip_history.some(r => r.action === 'DEPART') + && !snapshot.ll_loading_order_status_history.some(r => r.event_id === 'DISPATCH_DEPART:' + scope.dispatchNo), 'FRESH_DEPARTURE_REQUIRED') + for (const member of scope.members) { + const guards = snapshot.ll_physical_pallet_guard.filter(row => row.warehouse_pallet_id === member.palletId) + demand(guards.length === 1 && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{1,3})?$/.test(guards[0].created_at || ''), 'OWNED_PERMANENT_PHYSICAL_PALLET_GUARD_REQUIRED') + const order = exactRow(snapshot, 'll_order', member.orderId), transfer = exactRow(snapshot, 'll_warehouse_transfer_order', member.transferId) + const outbound = exactRow(snapshot, 'll_outbound_order', member.outboundId), delivery = exactRow(snapshot, 'll_delivery_order', member.deliveryId) + demand([order, transfer, outbound, delivery].every(r => r.customer_id === member.customerId) + && order.picked_package_count === '1' && order.shipped_package_count === '0' + && transfer.lastleg_order_id === member.orderId && transfer.transfer_no === member.incomingNo && transfer.outbound_at === null + && outbound.status === 'WAIT_OUTBOUND' && delivery.status === 'WAIT_DELIVERY' && delivery.dispatched_at === null, 'OWNED_SOURCE_AND_CHILD_BASELINE_REQUIRED') + const picks = snapshot.ll_dispatch_pda_pick.filter(r => r.customer_id === member.customerId) + const attribution = snapshot.ll_dispatch_pda_pick_order.filter(r => r.customer_id === member.customerId) + demand(picks.length === 1 && picks[0].task_id === scope.taskId && picks[0].dispatch_item_id === member.dispatchItemId + && picks[0].inventory_position_id === member.positionId && picks[0].packages === '1' && picks[0].return_carton_id === null + && attribution.length === 1 && attribution[0].pick_id === picks[0].id && attribution[0].order_id === member.orderId + && identifier(attribution[0].receipt_id) && attribution[0].packages === '1' && attribution[0].return_receipt_id === null + && attribution[0].return_carton_id === null && attribution[0].dispatch_item_source_id === null, 'COMMITTED_REAL_PICK_ATTRIBUTION_REQUIRED') + const ship = snapshot.ll_inventory_ledger.filter(r => r.customer_id === member.customerId && r.business_type === 'SHIP') + demand(ship.length === 1 && ship[0].business_order_no === scope.dispatchNo && ship[0].position_id === member.positionId + && ship[0].balance_id === member.balanceId && ship[0].delta_packages === '-1', 'PRIOR_COMMITTED_SHIP_REQUIRED') + const claims = snapshot.ll_dispatch_pallet_lock.filter(r => r.customer_id === member.customerId) + demand(claims.length === 1 && claims[0].dispatch_order_id === scope.dispatchId && claims[0].warehouse_pallet_id === member.palletId + && claims[0].status === 'ACTIVE' && identifier(claims[0].physical_lock_id), 'ACTIVE_OWNED_CLAIM_REQUIRED') + const physical = exactRow(snapshot, 'll_dispatch_physical_pallet_lock', claims[0].physical_lock_id) + demand(physical.warehouse_id === scope.warehouseId && physical.warehouse_pallet_id === member.palletId + && physical.active_warehouse_pallet_id === member.palletId && physical.dispatch_order_id === scope.dispatchId + && physical.status === 'ACTIVE' && physical.draft_token === null && physical.draft_container_id === null + && physical.expires_at === null && physical.released_at === null, 'ACTIVE_NATIVE_PHYSICAL_PALLET_OWNER_REQUIRED') + } + return snapshot +} + +export function assertTargetTransferUpdate(statement, scope) { + demand(typeof statement === 'string', 'TARGET_TRANSFER_UPDATE_NOT_PROVED') + const sql = statement.replace(/`/g, '').replace(/\s+/g, ' ').replace(/\s*([=,])\s*/g, '$1').trim() + const value = "(?:\\?|'[^']*')" + const match = sql.match(new RegExp(`^UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',\\s*outbound_at=${value},\\s*updated_at=${value} WHERE customer_id=(\\?|'[0-9]+') AND lastleg_order_id=(\\?|[0-9]+) AND outbound_at IS NULL$`, 'i')) + const second = scope.members[1] + demand(match && (match[1] === '?' || match[1] === `'${second.customerId}'`) && (match[2] === '?' || match[2] === second.orderId), 'TARGET_TRANSFER_UPDATE_NOT_PROVED') + return true +} + +export function assertMidTransactionObservation(rows, scope, baseline) { + const lease = one(rows, 'lease'), wait = one(rows, 'wait'), second = scope.members[1] + demand(identifier(lease.connectionId) && identifier(wait.requester) && wait.blocker === lease.connectionId && wait.requester !== wait.blocker + && identifier(lease.transactionId) && identifier(wait.requestingTransactionId) && wait.blockingTransactionId === lease.transactionId + && wait.requestingTransactionId !== lease.transactionId + && lease.lockId === second.transferId && wait.lockId === lease.lockId && wait.blockingLockId === lease.lockId + && /^X(?:,|$)/.test(lease.mode) && wait.objectName === 'll_warehouse_transfer_order' && wait.indexName === 'PRIMARY' + && wait.requestStatus === 'WAITING' && wait.blockingStatus === 'GRANTED' && wait.ownsRoot === 1, 'OWNED_LATE_ROW_WAIT_NOT_PROVED') + assertTargetTransferUpdate(wait.statement, scope) + demand(wait.sessionLockWaitSeconds === '50', 'REQUESTING_SESSION_50_SECOND_LOCK_TIMEOUT_NOT_PROVED') + const dirty = fullRollbackSnapshot(rows, scope) + for (const member of scope.members) { + const before = exactRow(baseline, 'll_order', member.orderId), order = exactRow(dirty, 'll_order', member.orderId) + const outbound = exactRow(dirty, 'll_outbound_order', member.outboundId), delivery = exactRow(dirty, 'll_delivery_order', member.deliveryId) + demand(outbound.status === 'OUTBOUNDED' && delivery.status === 'DELIVERING' && delivery.dispatched_at !== null + && BigInt(delivery.version) === BigInt(exactRow(baseline, 'll_delivery_order', member.deliveryId).version) + 1n + && BigInt(order.picked_package_count) === BigInt(before.picked_package_count) - 1n + && BigInt(order.shipped_package_count) === BigInt(before.shipped_package_count) + 1n + && order.outbound_order_no === member.outboundNo, 'CUSTOMER_UNCOMMITTED_CHILD_AND_SOURCE_WRITE_NOT_PROVED') + const history = dirty.ll_loading_order_status_history.filter(r => r.customer_id === member.customerId && r.order_id === member.orderId && r.event_id === 'DISPATCH_DEPART:' + scope.dispatchNo) + demand(history.length === 1 && !baseline.ll_loading_order_status_history.some(r => r.id === history[0].id), 'UNCOMMITTED_SOURCE_HISTORY_NOT_PROVED') + } + const firstTransfer = exactRow(dirty, 'll_warehouse_transfer_order', scope.members[0].transferId) + demand(firstTransfer.status === 'OUTBOUNDED' && firstTransfer.outbound_at !== null + && canonical(exactRow(dirty, 'll_warehouse_transfer_order', second.transferId)) === canonical(exactRow(baseline, 'll_warehouse_transfer_order', second.transferId)), 'FIRST_TRANSFER_WRITTEN_SECOND_TRANSFER_WAITING_NOT_PROVED') + for (const table of ['ll_dispatch_order', 'll_fleet_trip', 'll_dispatch_command', 'll_dispatch_history', 'll_fleet_trip_history', 'll_dispatch_pallet_lock', ...immutableTables]) + demand(canonical(dirty[table]) === canonical(baseline[table]), 'LATE_PHASE_OR_COMMITTED_SCAN_FACTS_CHANGED') + return { leaseConnectionId: lease.connectionId, requestingConnectionId: wait.requester, leaseTransactionId: lease.transactionId, + requestingTransactionId: wait.requestingTransactionId, lockId: lease.lockId, + sessionLockWaitSeconds: wait.sessionLockWaitSeconds, statement: wait.statement, dirtyObservationOnly: true, firstCustomerId: scope.members[0].customerId, secondCustomerId: second.customerId } +} + +export function assertRestoredSnapshot(baseline, restored) { + demand(canonical(baseline) === canonical(restored), 'FULL_FIELD_COMMITTED_ROLLBACK_SNAPSHOT_CHANGED') + return { beforeSha256: sha(baseline), afterSha256: sha(restored), fullFields: true, committedRead: true } +} +export function requestRollbackDeparture(context, path, body, token) { + // Existing 60-second, single-attempt channel: an unknown HTTP outcome is never retried here. + return context.requestWithBody('POST', path, body, token) +} +export function assertDatabaseLockConflict(response) { + demand(response?.http === 200 && String(response.body?.code) === 'A0409' && response.body?.message === '资源正在处理中,请稍后重试' + && response.requestId && response.traceId, 'EXPECTED_DATABASE_LOCK_CONFLICT_REQUIRED') +} +async function remote(env, scope, mode) { + const script = buildRollbackRemoteScript(scope, mode, env.FBA_CORE_JENKINS_AGENT) + const response = await fetch(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST', redirect: 'error', + signal: AbortSignal.timeout(mode === 'LEASE' ? 95000 : 45000), headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), + 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ script }) }) + demand(response.ok, 'ROLLBACK_EVIDENCE_HTTP_FAILED') + const lines = (await response.text()).split(/\r?\n/).filter(x => x.startsWith('TRIP_ROLLBACK_JSON=')) + demand(lines.length === 1, 'ROLLBACK_REMOTE_RESULT_AMBIGUOUS') + const result = JSON.parse(lines[0].slice('TRIP_ROLLBACK_JSON='.length)) + demand(result.ok === true, `ROLLBACK_${mode}_EVIDENCE_BLOCKED_${/^[A-Z_0-9]+$/.test(result.error || '') ? result.error : 'REMOTE_FAILED'}`) + const identity = one(result.rows, 'databaseRuntime') + demand(identity.performanceSchema === 1 && identity.serverUuid && identity.databaseName && /^sha256:[a-f0-9]{64}$/.test(result.imageId || ''), 'OBSERVABLE_DATABASE_RUNTIME_REQUIRED') + if (scope.runtime) demand(result.imageId === scope.expectedImage && canonical(identity) === canonical(scope.runtime), 'PINNED_DATABASE_RUNTIME_CHANGED') + return result +} + +export function createDepartureRollbackVerifier(env, config, runtime, { read = remote, wait = pause } = {}) { + return async ({ context, fixture, ready, departPath, departBody, snapshot }) => { + const output = resolve('reports', `fleet-cross-${config.runId}`, 'rollback.json'), fd = fs.openSync(output, 'wx', 0o600) + const originalCommand = canonical({ path: departPath, body: departBody }) + const report = { verified: false, scope: 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK', startedAt: new Date().toISOString(), samples: [], + command: JSON.parse(originalCommand), runtime } + const save = () => { fs.ftruncateSync(fd, 0); fs.writeSync(fd, JSON.stringify(report, null, 2), 0, 'utf8'); fs.fsyncSync(fd) } + let leasePromise, pending try { - demand(ready.trip.dispatches?.length===2&&fixture.members.length===2,'TWO_CUSTOMER_READY_SCOPE_REQUIRED') - const [first,second]=ready.trip.dispatches.map(binding=>fixture.members.find(x=>x.dispatchNo===binding.dispatchNo)) - demand(first&&second&&first.customerId!==second.customerId&&first.dispatchNo===fixture.members[0].dispatchNo,'CURRENT_DEPARTURE_ORDER_REQUIRED') - const firstState=ready.members[fixture.members.indexOf(first)],secondState=ready.members[fixture.members.indexOf(second)] - const scope=report.target=validateRollbackScope({marker:context.marker,tripNo:fixture.tripNo,expectedImage:runtime.imageId, - firstCustomerId:first.customerId,secondCustomerId:second.customerId,firstDispatchId:id(firstState.dispatch.id),secondDispatchId:id(secondState.dispatch.id), - firstOutboundId:id(firstState.outbound.outbound.id),secondPalletId:second.palletId,firstDispatchNo:first.dispatchNo}) - save();leasePromise=remote(env,scope,'LEASE').then(value=>({value}),error=>({error:error.message})) - for(let i=0;i<12;i++) { - const rows=(await remote(env,scope,'LOCKS')).rows;report.samples.push({phase:'LEASE',rows});save() - if(rows.filter(x=>x.collection==='lease').length===1)break - demand(i!==11,'LIVE_LEASE_NOT_OBSERVED');await pause(250) + demand(ready.trip.dispatches?.length === 1 && ready.trip.dispatches[0].dispatchNo === fixture.dispatchNo && fixture.members.length === 2 + && ready.dispatch.dispatchNo === fixture.dispatchNo && String(ready.task.recordVersion) === String(departBody.recordVersion), 'ONE_NATIVE_READY_SCOPE_REQUIRED') + const request = validateRollbackRequest({ marker: context.marker, tripNo: fixture.tripNo, dispatchNo: fixture.dispatchNo, warehouseId: fixture.warehouseId, + expectedImage: runtime.imageId, runtime: one(runtime.rows, 'databaseRuntime'), commandKey: departBody.idempotencyKey, commandVersion: String(departBody.recordVersion), + members: fixture.members.map(({ customerId, incomingNo, dispatchItemId, palletId, positionId, balanceId, outboundNo, deliveryNo }) => + ({ customerId, incomingNo, dispatchItemId, palletId, positionId, balanceId, outboundNo, deliveryNo })) }) + const resolved = await read(env, request, 'RESOLVE') + const scope = report.target = resolveRollbackScope(request, resolved.rows) + const baseline = report.baseline = assertRollbackBaseline(fullRollbackSnapshot((await read(env, scope, 'BASELINE')).rows, scope), scope) + save() + leasePromise = read(env, scope, 'LEASE').then(value => ({ value }), error => ({ error: error.message })) + let lease + for (let i = 0; i < 12; i++) { + const rows = (await read(env, scope, 'LOCKS')).rows; report.samples.push({ phase: 'LEASE', rows }); save() + if (rows.filter(x => x.collection === 'lease').length === 1) { + lease = one(rows, 'lease') + demand(lease.lockId === scope.members[1].transferId && identifier(lease.connectionId) && /^X(?:,|$)/.test(lease.mode) + && Number.isSafeInteger(lease.ageSeconds) && lease.ageSeconds >= 0 && lease.ageSeconds <= 5, 'FRESH_OWNED_65_SECOND_LEASE_REQUIRED') + break + } + demand(i !== 11, 'LIVE_LEASE_NOT_OBSERVED'); await wait(250) } - pending=requestRollbackDeparture(context,departPath,departBody,fixture.pdaToken).then(value=>({value}),error=>({error:error.message})) - for(let i=0;i<20;i++) { - const rows=(await remote(env,scope,'OBSERVE')).rows;report.samples.push({phase:'MID_TRANSACTION',rows});save() - if(rows.some(x=>x.collection==='wait')) {report.observation=assertMidTransactionObservation(rows);save();break} - demand(i!==19,'MID_TRANSACTION_NOT_OBSERVED');await pause(250) + pending = requestRollbackDeparture(context, departPath, departBody, fixture.pdaToken).then(value => ({ value }), error => ({ error: error.message })) + for (let i = 0; i < 20; i++) { + const rows = (await read(env, scope, 'OBSERVE')).rows; report.samples.push({ phase: 'MID_TRANSACTION', rows }); save() + if (rows.some(x => x.collection === 'wait')) { + report.observation = assertMidTransactionObservation(rows, scope, baseline) + demand(report.observation.leaseConnectionId === lease.connectionId && report.observation.leaseTransactionId === lease.transactionId, 'LEASE_CONNECTION_CHANGED') + scope.observedTransactions = [report.observation.leaseTransactionId, report.observation.requestingTransactionId] + save(); break + } + demand(i !== 19, 'MID_TRANSACTION_NOT_OBSERVED'); await wait(250) } - report.response=await pending;save() - report.lease=await leasePromise;save() - demand(!report.lease.error&&report.lease.value.rows.at(-1)?.released===true,'LEASE_RELEASE_NOT_PROVED') - const released=report.released=(await remote(env,scope,'RELEASED')).rows - demand(released.length===0,'OWNED_ROW_LOCK_OR_WAITER_REMAINS') - const restored=await snapshot('ROLLBACK_AFTER_FAULT') - report.consistentSnapshot={beforeSha256:createHash('sha256').update(canonical(ready)).digest('hex'),afterSha256:createHash('sha256').update(canonical(restored)).digest('hex')} - demand(report.consistentSnapshot.beforeSha256===report.consistentSnapshot.afterSha256,'FULL_TWO_CUSTOMER_ROLLBACK_SNAPSHOT_CHANGED') - demand(!report.response.error&&report.response.value?.http!==0,'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') + report.response = await pending; save() + report.lease = await leasePromise; save() + demand(!report.lease.error && report.lease.value.rows.at(-1)?.released === true, 'LEASE_RELEASE_NOT_PROVED') + const held = one(report.lease.value.rows, 'held') + demand(held.lockId === lease.lockId && held.connectionId === lease.connectionId && held.customerId === scope.members[1].customerId, 'ORIGINAL_LEASE_RESULT_CHANGED') + const released = report.released = (await read(env, scope, 'RELEASED')).rows + demand(!released.some(r => ['lease', 'wait', 'remainingTransaction'].includes(r.collection)), 'OWNED_ROW_LOCK_OR_TRANSACTION_REMAINS') + // A new RR consistent snapshot after the server outcome and lease completion, never the dirty observer transaction. + report.restored = fullRollbackSnapshot((await read(env, scope, 'RESTORED')).rows, scope) + report.consistentSnapshot = assertRestoredSnapshot(baseline, report.restored) + demand(!report.response.error && report.response.value?.http !== 0, 'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') assertDatabaseLockConflict(report.response.value) - // The caller also checks restoration before sending the same original command. - report.verified=true;return report - }catch(error){report.error=error.message;throw error} + demand(canonical(ready) === canonical(await snapshot('ROLLBACK_AFTER_FAULT')), 'PUBLIC_AND_COMPLETE_INVENTORY_SNAPSHOT_CHANGED') + demand(canonical({ path: departPath, body: departBody }) === originalCommand, 'ORIGINAL_DEPART_COMMAND_CHANGED') + // The existing caller now sends this identical original command once, then verifies replay and both customer receipts. + report.verified = true; return report + } catch (error) { report.error = error.message; throw error } finally { - if(pending&&!report.response)report.response=await pending - if(leasePromise&&!report.lease)report.lease=await leasePromise - report.finishedAt=new Date().toISOString();save();fs.closeSync(fd) + if (pending && !report.response) report.response = await pending + if (leasePromise && !report.lease) report.lease = await leasePromise + report.finishedAt = new Date().toISOString(); save(); fs.closeSync(fd) } } } -export async function runTripRollback(config,{env=process.env}={}) { - if(!config.execute)return {...config,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK',fault:'65-second SELECT FOR UPDATE lease on the second customer pallet-lock row; first customer writes observed as uncommitted, default 50-second lock timeout rolls back the entire original request; then same original command and full normal chain',businessSqlWrites:false,deployment:false} - demand(env.CHONGCHONG_ENV==='test'&&['JENKINS_URL','JENKINS_USER','JENKINS_API_TOKEN','FBA_CORE_JENKINS_AGENT'].every(key=>env[key]),'EXPLICIT_TEST_EVIDENCE_PROFILE_REQUIRED') - const runtime=await remote(env,{},'RUNTIME');demand(runtime.rows.length===1&&runtime.rows[0].lockWaitSeconds===50,'DEFAULT_50_SECOND_LOCK_TIMEOUT_REQUIRED') - const sources=['scripts/fleet-trip-rollback-test-env.mjs','scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs'].map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(path)).digest('hex')})) - return runCrossCustomer(config,{env,verify:async(context,fixture,options)=>{ - fs.writeFileSync(resolve('reports',`fleet-cross-${config.runId}`,'rollback-source.json'),JSON.stringify({runtime,sources},null,2),{flag:'wx'}) - return verifyFleetCrossCustomer(context,fixture,{...options,verifyDepartureRollback:createDepartureRollbackVerifier(env,config,runtime)}) - }}) + +export async function runTripRollback(config, { env = process.env } = {}) { + if (!config.execute) return { ...config, scope: 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK', fault: 'One native dispatch; two ordinary WAREHOUSE_TRANSFER customers in Java string order; 65-second read-only row lease on the second transfer; exact UPDATE waiter and its own 50-second session timeout; uncommitted first customer writes; fresh full-field committed rollback baseline; same original command and normal signing chain', businessSqlWrites: false, deployment: false } + demand(config.environment === 'TEST' && env.CHONGCHONG_ENV === 'test' && ['JENKINS_URL', 'JENKINS_USER', 'JENKINS_API_TOKEN', 'FBA_CORE_JENKINS_AGENT'].every(key => env[key]), 'EXPLICIT_TEST_EVIDENCE_PROFILE_REQUIRED') + const runtime = await remote(env, {}, 'RUNTIME') + demand(runtime.rows.filter(row => row.collection === 'privilege').length === 5, 'LOCK_AND_SESSION_OBSERVABILITY_REQUIRED') + const sources = ['scripts/fleet-trip-rollback-test-env.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs'].map(path => ({ path, sha256: createHash('sha256').update(fs.readFileSync(path)).digest('hex') })) + return runCrossCustomer(config, { env, verify: async (context, fixture, options) => { + fs.writeFileSync(resolve('reports', `fleet-cross-${config.runId}`, 'rollback-source.json'), JSON.stringify({ runtime, sources }, null, 2), { flag: 'wx' }) + return verifyFleetCrossCustomer(context, fixture, { ...options, verifyDepartureRollback: createDepartureRollbackVerifier(env, config, runtime) }) + } }) } -if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) { - try {const config=parseCrossCustomerArguments(process.argv.slice(2)),result=await runTripRollback(config);console.log(JSON.stringify(config.execute?{success:result.success,error:result.error,summary:result.summary,requests:result.requestCount,goalComplete:false}:result,null,2));if(config.execute&&!result.success)process.exitCode=1} - catch(error){console.error(/^[A-Z_0-9]+$/.test(error.message)?error.message:'ROLLBACK_ENTRY_FAILED');process.exitCode=1} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { const config = parseCrossCustomerArguments(process.argv.slice(2)), result = await runTripRollback(config); console.log(JSON.stringify(config.execute ? { success: result.success, error: result.error, summary: result.summary, requests: result.requestCount, goalComplete: false } : result, null, 2)); if (config.execute && !result.success) process.exitCode = 1 } + catch (error) { console.error(/^[A-Z_0-9]+$/.test(error.message) ? error.message : 'ROLLBACK_ENTRY_FAILED'); process.exitCode = 1 } } diff --git a/scripts/fleet-trip-rollback-test-env.test.mjs b/scripts/fleet-trip-rollback-test-env.test.mjs index f8f76d297..7025bac55 100644 --- a/scripts/fleet-trip-rollback-test-env.test.mjs +++ b/scripts/fleet-trip-rollback-test-env.test.mjs @@ -1,51 +1,355 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { validateRollbackScope, rollbackSql, buildRollbackRemoteScript, assertMidTransactionObservation, runTripRollback, requestRollbackDeparture, assertDatabaseLockConflict } from './fleet-trip-rollback-test-env.mjs' -const scope={marker:'OWN_RUN',tripNo:'TR-OWN',firstCustomerId:'101',secondCustomerId:'102',firstDispatchId:'201',secondDispatchId:'202',firstOutboundId:'301',secondPalletId:'401',firstDispatchNo:'DS-OWN',expectedImage:'sha256:'+'a'.repeat(64)} -const rows=()=>[{collection:'lease',connectionId:11,lockId:'99'},{collection:'wait',requester:12,blocker:11,lockId:'99'}, - {collection:'firstDispatch',id:'201',status:'DEPARTED',shipped:1},{collection:'firstOutbound',id:'301',status:'OUTBOUNDED'},{collection:'firstReplay',count:1}] -test('fault request allows database timeout and never retries an unknown write',async()=>{ - const body={recordVersion:'11',idempotencyKey:'ORIGINAL'},calls=[] - const context={request:()=>{throw Error('30_SECOND_CHANNEL')},requestWithBody:async(...args)=>{calls.push(args);return {http:0,body:null}}} - assert.equal((await requestRollbackDeparture(context,'/owned/depart',body,'owned-token')).http,0) - assert.deepEqual(calls,[['POST','/owned/depart',body,'owned-token']]) +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { validateRollbackRequest, validateRollbackScope, resolveRollbackScope, rollbackSql, buildRollbackRemoteScript, + fullRollbackSnapshot, assertRollbackBaseline, assertTargetTransferUpdate, assertMidTransactionObservation, assertRestoredSnapshot, + runTripRollback, requestRollbackDeparture, assertDatabaseLockConflict, createDepartureRollbackVerifier } from './fleet-trip-rollback-test-env.mjs' + +const sqlSource = fs.readFileSync(new URL('../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8') +// Read actual CREATE columns (including multiple columns on a line), respecting quoted comments and nested type parentheses. +function ddlColumns(table) { + const body = sqlSource.match(new RegExp(`CREATE TABLE(?: IF NOT EXISTS)? ${table} \\(([\\s\\S]*?)\\) ENGINE=`, 'i'))?.[1] + assert.ok(body, `actual schema table ${table}`) + const parts = []; let start = 0, depth = 0, quoted = false + for (let i = 0; i < body.length; i++) { + if (body[i] === "'" && body[i - 1] !== '\\') quoted = !quoted + if (!quoted) { + if (body[i] === '(') depth++ + if (body[i] === ')') depth-- + if (body[i] === ',' && depth === 0) { parts.push(body.slice(start, i)); start = i + 1 } + } + } + parts.push(body.slice(start)) + const primary = body.match(/PRIMARY KEY\s*\(([^)]+)\)/i)[1].split(',').map(c => c.trim()) + return parts.map(part => part.trim().match(/^([a-z][a-z0-9_]*)\s+((?:BIGINT|INT|VARCHAR|CHAR|TEXT|LONGTEXT|MEDIUMTEXT|DATETIME|TIMESTAMP|DATE|TIME|JSON|DECIMAL|TINYINT|SMALLINT|BOOLEAN|BIT|DOUBLE|FLOAT)(?:\([^)]*\))?)/i)) + .filter(Boolean).map((match, i) => ({ collection: 'column', table, column: match[1], ordinal: i + 1, type: match[2].toLowerCase(), key: primary.includes(match[1]) ? 'PRI' : '' })) +} +const tableNames = ['ll_dispatch_order', 'll_dispatch_history', 'll_dispatch_item', 'll_dispatch_command', 'll_dispatch_pallet_lock', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard', 'll_dispatch_item_source', + 'll_fleet_trip', 'll_fleet_trip_dispatch', 'll_fleet_trip_history', 'll_fleet_pool_order_link', 'll_outbound_order', 'll_delivery_order', + 'll_order', 'll_loading_order_status_history', 'll_warehouse_transfer_order', 'll_dispatch_pda_task', 'll_dispatch_pda_pick', + 'll_dispatch_pda_pick_order', 'll_inventory_balance', 'll_inventory_position', 'll_inventory_ledger'] +const columns = [...tableNames].sort().flatMap(ddlColumns) +const request = { marker: 'OWN_RUN', tripNo: 'TR-OWN', dispatchNo: 'DS-OWN', warehouseId: '51', commandKey: 'ORIGINAL-DEPART', commandVersion: '11', expectedImage: 'sha256:' + 'a'.repeat(64), + // Reverse input and numeric ordering deliberately differ from Java string ordering. + members: [{ customerId: '99', incomingNo: 'WT-99', dispatchItemId: '302', palletId: '402', positionId: '502', balanceId: '602', outboundNo: 'OB-99', deliveryNo: 'DL-99' }, + { customerId: '101', incomingNo: 'WT-101', dispatchItemId: '301', palletId: '401', positionId: '501', balanceId: '601', outboundNo: 'OB-101', deliveryNo: 'DL-101' }] } +function resolution() { + return [{ collection: 'root', id: '201', dispatchNo: request.dispatchNo, warehouseId: '51', status: 'ACTIVE', stage: 'AFTER_LOADING', version: '11' }, + { collection: 'trip', id: '211', tripNo: request.tripNo, warehouseId: '51', status: 'PLANNED' }, + { collection: 'binding', dispatchId: '201', dispatchNo: request.dispatchNo, tripId: '211', tripNo: request.tripNo }, + { collection: 'task', id: '221', dispatchId: '201', warehouseId: '51' }, ...columns, + ...request.members.flatMap(m => { + const suffix = m.customerId === '101' ? '1' : '2' + return [{ collection: 'item', id: m.dispatchItemId, dispatchId: '201', customerId: m.customerId, palletId: m.palletId, orderId: '70' + suffix, sourceType: null, sourceNo: null, incomingNo: m.incomingNo, packages: '1' }, + { collection: 'transfer', id: '80' + suffix, customerId: m.customerId, incomingNo: m.incomingNo, orderId: '70' + suffix, warehouseId: '51', needRelabel: '0', relabelNo: null, outboundAt: null }, + { collection: 'outbound', id: '90' + suffix, customerId: m.customerId, dispatchId: '201', outboundNo: m.outboundNo, status: 'WAIT_OUTBOUND' }, + { collection: 'delivery', id: '100' + suffix, customerId: m.customerId, dispatchId: '201', deliveryNo: m.deliveryNo, outboundNo: m.outboundNo, status: 'WAIT_DELIVERY' }] + })] +} +const scope = resolveRollbackScope(request, resolution()) +const row = (table, values) => { + const actual = columns.filter(c => c.table === table).map(c => c.column) + for (const key of Object.keys(values)) assert.ok(actual.includes(key), `real ${table}.${key}`) + return { ...Object.fromEntries(actual.map(c => [c, null])), ...values } +} +function baseline() { + const value = Object.fromEntries(tableNames.map(table => [table, []])) + value.ll_dispatch_order.push(row('ll_dispatch_order', { id: '201', dispatch_no: request.dispatchNo, trip_id: '211', overseas_warehouse_id: '51', status: 'ACTIVE', stage: 'AFTER_LOADING', record_version: '11', shipped_package_count: '0' })) + value.ll_fleet_trip.push(row('ll_fleet_trip', { id: '211', trip_no: request.tripNo, status: 'PLANNED', record_version: '7' })) + value.ll_fleet_trip_dispatch.push(row('ll_fleet_trip_dispatch', { dispatch_id: '201', dispatch_no: request.dispatchNo, trip_id: '211', trip_no: request.tripNo })) + value.ll_dispatch_pda_task.push(row('ll_dispatch_pda_task', { id: '221', dispatch_order_id: '201' })) + for (const [index, m] of scope.members.entries()) { + const suffix = String(index + 1) + value.ll_dispatch_item.push(row('ll_dispatch_item', { id: m.dispatchItemId, dispatch_order_id: '201', customer_id: m.customerId })) + value.ll_outbound_order.push(row('ll_outbound_order', { id: m.outboundId, customer_id: m.customerId, dispatch_order_id: '201', outbound_no: m.outboundNo, status: 'WAIT_OUTBOUND' })) + value.ll_delivery_order.push(row('ll_delivery_order', { id: m.deliveryId, customer_id: m.customerId, dispatch_order_id: '201', delivery_no: m.deliveryNo, status: 'WAIT_DELIVERY', version: '0' })) + value.ll_order.push(row('ll_order', { id: m.orderId, customer_id: m.customerId, picked_package_count: '1', shipped_package_count: '0' })) + value.ll_warehouse_transfer_order.push(row('ll_warehouse_transfer_order', { id: m.transferId, customer_id: m.customerId, lastleg_order_id: m.orderId, transfer_no: m.incomingNo, status: 'RECEIVED' })) + value.ll_dispatch_pallet_lock.push(row('ll_dispatch_pallet_lock', { id: '110' + suffix, physical_lock_id: '170' + suffix, customer_id: m.customerId, dispatch_order_id: '201', warehouse_pallet_id: m.palletId, status: 'ACTIVE', active_key: 'OWN-' + suffix })) + value.ll_dispatch_physical_pallet_lock.push(row('ll_dispatch_physical_pallet_lock', { id: '170' + suffix, warehouse_id: '51', + warehouse_pallet_id: m.palletId, active_warehouse_pallet_id: m.palletId, dispatch_order_id: scope.dispatchId, status: 'ACTIVE' })) + value.ll_physical_pallet_guard.push(row('ll_physical_pallet_guard', { warehouse_pallet_id: m.palletId, created_at: `2026-10-19 11:00:00.00${suffix}` })) + value.ll_dispatch_pda_pick.push(row('ll_dispatch_pda_pick', { id: '120' + suffix, customer_id: m.customerId, task_id: '221', dispatch_item_id: m.dispatchItemId, inventory_position_id: m.positionId, packages: '1' })) + value.ll_dispatch_pda_pick_order.push(row('ll_dispatch_pda_pick_order', { id: '130' + suffix, customer_id: m.customerId, pick_id: '120' + suffix, receipt_id: '140' + suffix, order_id: m.orderId, packages: '1' })) + value.ll_inventory_balance.push(row('ll_inventory_balance', { id: m.balanceId, customer_id: m.customerId, overseas_warehouse_id: '51', available_packages: '0' })) + value.ll_inventory_position.push(row('ll_inventory_position', { id: m.positionId, balance_id: m.balanceId, customer_id: m.customerId, overseas_warehouse_id: '51', available_packages: '0' })) + value.ll_inventory_ledger.push(row('ll_inventory_ledger', { id: '150' + suffix, customer_id: m.customerId, overseas_warehouse_id: '51', business_type: 'SHIP', business_order_no: request.dispatchNo, balance_id: m.balanceId, position_id: m.positionId, delta_packages: '-1' })) + } + return value +} +function snapshotRows(snapshot) { + return [...structuredClone(columns), ...tableNames.flatMap(table => [{ collection: 'coverage', table, count: snapshot[table].length }, + ...snapshot[table].map(value => ({ collection: 'row', table, row: structuredClone(value) }))])] +} +const updateSql = "UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=?,updated_at=? WHERE customer_id=? AND lastleg_order_id=? AND outbound_at IS NULL" +const lease = () => ({ collection: 'lease', connectionId: '11', transactionId: '111', lockId: '802', mode: 'X,REC_NOT_GAP', ageSeconds: 1 }) +function midRows() { + const dirty = baseline() + for (const [index, m] of scope.members.entries()) { + Object.assign(dirty.ll_outbound_order[index], { status: 'OUTBOUNDED' }) + Object.assign(dirty.ll_delivery_order[index], { status: 'DELIVERING', dispatched_at: '2026-10-19 12:00:00.000', version: '1' }) + Object.assign(dirty.ll_order[index], { picked_package_count: '0', shipped_package_count: '1', outbound_order_no: m.outboundNo }) + dirty.ll_loading_order_status_history.push(row('ll_loading_order_status_history', { id: '160' + (index + 1), customer_id: m.customerId, order_id: m.orderId, event_id: 'DISPATCH_DEPART:' + scope.dispatchNo })) + } + Object.assign(dirty.ll_warehouse_transfer_order[0], { status: 'OUTBOUNDED', outbound_at: '2026-10-19 12:00:00.000' }) + return [lease(), { collection: 'wait', requester: '12', blocker: '11', requestingTransactionId: '112', blockingTransactionId: '111', lockId: '802', blockingLockId: '802', objectName: 'll_warehouse_transfer_order', indexName: 'PRIMARY', + requestStatus: 'WAITING', blockingStatus: 'GRANTED', ownsRoot: 1, statement: updateSql, sessionLockWaitSeconds: '50' }, ...snapshotRows(dirty)] +} +const conflict = () => ({ http: 200, body: { code: 'A0409', message: '资源正在处理中,请稍后重试' }, requestId: 'request', traceId: 'trace' }) + +test('business-only request resolves one native root and real customer children in Java string order', () => { + assert.equal(scope.dispatchId, '201'); assert.equal(scope.members[0].customerId, '101'); assert.equal(scope.members[1].customerId, '99') + assert.equal(scope.members[1].transferId, '802'); assert.equal(request.members[0].customerId, '99') + assert.doesNotThrow(() => validateRollbackScope(scope)) + const sql = rollbackSql(request, 'RESOLVE') + assert.match(sql, /ll_dispatch_order WHERE dispatch_no=/) + assert.match(sql, /'incomingNo',CAST\(inbound_code AS CHAR\)/) + assert.doesNotMatch(sql, /FOR UPDATE|firstDispatchId|secondDispatchId/) }) -test('database conflict requires exact handler response and trace, not arbitrary rejection',()=>{ - const response={http:200,body:{code:'A0409',message:'资源正在处理中,请稍后重试'},requestId:'request',traceId:'trace'} - assert.doesNotThrow(()=>assertDatabaseLockConflict(response)) - for(const wrong of [{http:0},{http:500},{body:{code:'S0500',message:'error'}},{body:{code:'A0409',message:'请完成拣货'}},{traceId:null}]) - assert.throws(()=>assertDatabaseLockConflict({...response,...wrong})) +for (const [name, mutate] of [ + ['two roots', rows => rows.push({ ...rows[0], id: '999' })], + ['two Fleet bindings', rows => rows.push({ ...rows.find(r => r.collection === 'binding'), dispatchId: '999' })], + ['wrong root version', rows => { rows[0].version = '12' }], + ['invented ordinary source alias', rows => { rows.find(r => r.collection === 'item').sourceType = 'WAREHOUSE_TRANSFER' }], + ['relabel source', rows => { rows.find(r => r.collection === 'transfer').needRelabel = '1' }], + ['wrong incoming', rows => { rows.find(r => r.collection === 'transfer').incomingNo = 'BORROWED' }], + ['foreign child', rows => { rows.find(r => r.collection === 'delivery').dispatchId = '999' }], + ['missing private ID', rows => { rows.find(r => r.collection === 'transfer').id = undefined }], + ['truncated schema', rows => rows.splice(rows.findIndex(r => r.collection === 'column' && r.table === 'll_order'), 1)], +]) test(`native resolution blocks ${name}`, () => { + const rows = resolution(); mutate(rows); assert.throws(() => resolveRollbackScope(request, rows)) }) -test('plan and import are network-free; execution rejects missing TEST profile before any network',async()=>{ - const plan=await runTripRollback({execute:false},{env:{}}) - assert.equal(plan.businessSqlWrites,false);assert.equal(plan.deployment,false) - await assert.rejects(runTripRollback({execute:true},{env:{CHONGCHONG_ENV:'prod'}}),/TEST/) +test('scope rejects injection, repeated ownership, reversed sort and unpinned image', () => { + for (const change of [{ marker: "OWN';DELETE" }, { expectedImage: 'latest' }, { dispatchId: '1 OR 1=1' }, { members: [...scope.members].reverse() }]) + assert.throws(() => validateRollbackScope({ ...scope, ...change })) + const bad = structuredClone(request); bad.members[1].customerId = bad.members[0].customerId + assert.throws(() => validateRollbackRequest(bad)) }) -test('scope rejects reused customers, injection and unpinned runtime',()=>{ - assert.equal(validateRollbackScope(scope),scope) - for(const changed of [{secondCustomerId:'101'},{secondDispatchId:'201'},{firstDispatchId:'1 OR 1=1'},{marker:"RUN';DELETE"},{expectedImage:'latest'}])assert.throws(()=>validateRollbackScope({...scope,...changed})) +test('lease is only the second actual transfer PK, fixed read-only lease and automatic rollback', () => { + const sql = rollbackSql(scope, 'LEASE') + assert.match(sql, /FROM ll_warehouse_transfer_order WHERE id=802 AND customer_id=/) + assert.match(sql, /lastleg_order_id=702 AND transfer_no=/) + assert.match(sql, /outbound_at IS NULL FOR UPDATE;.*SLEEP\(65\)\);ROLLBACK;/) + assert.doesNotMatch(sql, /\b(?:INSERT|DELETE|ALTER|DROP|TRUNCATE|REPLACE|KILL|SET GLOBAL)\b|ll_dispatch_pallet_lock/) + assert.equal((sql.match(/\bUPDATE\b/g) || []).length, 1) + assert.throws(() => rollbackSql(scope, 'CUSTOM_SQL')) }) -test('lease contains only owned locking reads, fixed duration and rollback, without mutation or schema change',()=>{ - const sql=rollbackSql(scope,'LEASE') - assert.match(sql,/dispatch_order_id=202/);assert.match(sql,/warehouse_pallet_id=401/);assert.match(sql,/FOR UPDATE;.*SLEEP\(65\)\);ROLLBACK;/) - assert.doesNotMatch(sql,/\b(?:INSERT|DELETE|ALTER|DROP|TRUNCATE|REPLACE|KILL|SET GLOBAL)\b/) - assert.equal((sql.match(/UPDATE/g)||[]).length,1) - assert.throws(()=>rollbackSql(scope,'CUSTOM_SQL')) - assert.throws(()=>buildRollbackRemoteScript(scope,'LEASE',"agent');evil()")) +test('wait proof joins exact granted/blocking PK, real transaction SQL, root ownership and requester session variable', () => { + const sql = rollbackSql(scope, 'OBSERVE') + for (const text of ['READ UNCOMMITTED', 'w.BLOCKING_ENGINE_LOCK_ID', "bl.LOCK_DATA=l.LOCK_DATA", "l.LOCK_DATA='802'", 'r.trx_query', + 'v.THREAD_ID=th.THREAD_ID', 'th.PROCESSLIST_ID=r.trx_mysql_thread_id', "v.VARIABLE_NAME='innodb_lock_wait_timeout'", "dl.LOCK_DATA='201'"]) + assert.ok(sql.includes(text), text) + assert.doesNotMatch(sql, /@@GLOBAL|ll_dispatch_pda_operation|DEPARTED/) + assert.match(rollbackSql(scope, 'RESTORED'), /REPEATABLE READ;\nSTART TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;/) + assert.doesNotMatch(rollbackSql(scope, 'RESTORED'), /READ UNCOMMITTED/) }) -test('dirty observation is separate from final consistent cleanup evidence',()=>{ - assert.match(rollbackSql(scope,'OBSERVE'),/READ UNCOMMITTED/) - assert.match(rollbackSql(scope,'RELEASED'),/REPEATABLE READ/) - assert.match(rollbackSql(scope,'RELEASED'),/READ ONLY/) - assert.equal(assertMidTransactionObservation(rows()).dirtyObservationOnly,true) +test('generated resolution and snapshot projections use current native schema columns', () => { + const sql = rollbackSql(request, 'RESOLVE') + '\n' + rollbackSql(scope, 'BASELINE') + for (const query of sql.split(';')) { + const match = query.match(/SELECT JSON_OBJECT\([\s\S]*? FROM (ll_[a-z_]+) WHERE/) + if (!match) continue + const actual = new Set(ddlColumns(match[1]).map(c => c.column)) + for (const column of query.slice(0, match.index + match[0].indexOf(' FROM ')).matchAll(/CAST\(`?([a-z_]+)`? AS CHAR/g)) + assert.ok(actual.has(column[1]), `${match[1]}.${column[1]}`) + } + assert.ok(columns.filter(c => c.table === 'll_order').length > 50) + assert.doesNotThrow(() => assertRollbackBaseline(fullRollbackSnapshot(snapshotRows(baseline()), scope), scope)) }) -for(const fault of ['foreign blocker','no wait','two waiters','not yet written','no outbound write','no replay write'])test(`refuses indirect mid-transaction evidence: ${fault}`,()=>{ - const value=rows() - if(fault==='foreign blocker')value[1].blocker=77 - if(fault==='no wait')value.splice(1,1) - if(fault==='two waiters')value.push({...value[1],requester:13}) - if(fault==='not yet written')value[2].status='READY_TO_DEPART' - if(fault==='no outbound write')value[3].status='WAIT_OUTBOUND' - if(fault==='no replay write')value[4].count=0 - assert.throws(()=>assertMidTransactionObservation(value)) +test('remote envelope pins TEST container, healthy image, DB identity and preflights observation permissions', () => { + function decode(script) { const encoded = script.match(/decoder.decode\(\[((?:'[A-Za-z0-9+/=]*',?)+)\]\.join\(''\)\)/)[1]; return Buffer.from([...encoded.matchAll(/'([^']*)'/g)].map(m => m[1]).join(''), 'base64').toString() } + const python = decode(decode(buildRollbackRemoteScript(scope, 'OBSERVE', 'test-agent'))) + for (const text of ['firstleg-test-lastleg', 'firstleg-test', "e.get('DEPLOY_ENV')!='test'", "get('Status')!='healthy'", scope.expectedImage, 'MYSQL_PWD', 'variables_by_thread']) assert.ok(python.includes(text), text) + assert.ok(buildRollbackRemoteScript({}, 'RUNTIME', 'test-agent')) + assert.throws(() => buildRollbackRemoteScript(scope, 'LEASE', "agent');evil()")) +}) +test('full-field baseline cannot accept missing columns, rows, coverage or changed schema', () => { + for (const mutate of [ + rows => { delete rows.find(r => r.collection === 'row').row.updated_at }, + rows => rows.splice(rows.findIndex(r => r.collection === 'row'), 1), + rows => rows.splice(rows.findIndex(r => r.collection === 'coverage'), 1), + rows => { rows.find(r => r.collection === 'column').type = 'changed' }, + rows => rows.push({ ...rows.find(r => r.collection === 'coverage') }), + ]) { const rows = snapshotRows(baseline()); mutate(rows); assert.throws(() => fullRollbackSnapshot(rows, scope)) } +}) +for (const [name, mutate] of [ + ['no committed SHIP', b => { b.ll_inventory_ledger = [] }], + ['wrong scan source', b => { b.ll_dispatch_pda_pick_order[0].order_id = '999' }], + ['prior departure command', b => { b.ll_dispatch_command.push(row('ll_dispatch_command', { id: '999', action: 'DEPART' })) }], + ['source contribution on ordinary transfer', b => { b.ll_dispatch_item_source.push(row('ll_dispatch_item_source', { id: '991', dispatch_order_id: '201', dispatch_item_id: scope.members[0].dispatchItemId })) }], + ['ordinary receipt also claims relabel contribution', b => { b.ll_dispatch_pda_pick_order[0].dispatch_item_source_id = '991' }], + ['ordinary receipt also claims native return carton', b => { b.ll_dispatch_pda_pick_order[0].return_carton_id = '991' }], + ['missing physical lock', b => { b.ll_dispatch_physical_pallet_lock.pop() }], + ['missing permanent physical guard', b => { b.ll_physical_pallet_guard.pop() }], + ['guard for a foreign pallet', b => { b.ll_physical_pallet_guard[0].warehouse_pallet_id = '991' }], + ['extra foreign permanent guard', b => { b.ll_physical_pallet_guard.push(row('ll_physical_pallet_guard', { warehouse_pallet_id: '991', created_at: '2026-10-19 11:00:00.001' })) }], + ['guard without original created timestamp', b => { b.ll_physical_pallet_guard[0].created_at = null }], + ['guard with invalid creation timestamp', b => { b.ll_physical_pallet_guard[0].created_at = 'NOT-A-CREATION-TIME' }], + ['logical claim points to other physical pallet', b => { b.ll_dispatch_pallet_lock[0].physical_lock_id = b.ll_dispatch_physical_pallet_lock[1].id }], + ['logical claim on other main', b => { b.ll_dispatch_pallet_lock[0].dispatch_order_id = '991' }], + ['physical claim on other main', b => { b.ll_dispatch_physical_pallet_lock[0].dispatch_order_id = '991' }], + ['physical claim on other warehouse', b => { b.ll_dispatch_physical_pallet_lock[0].warehouse_id = '991' }], + ['physical claim for other pallet', b => { b.ll_dispatch_physical_pallet_lock[0].warehouse_pallet_id = '991' }], + ['physical active key mismatch', b => { b.ll_dispatch_physical_pallet_lock[0].active_warehouse_pallet_id = '991' }], + ['physical claim released', b => { b.ll_dispatch_physical_pallet_lock[0].status = 'RELEASED'; b.ll_dispatch_physical_pallet_lock[0].active_warehouse_pallet_id = null }], + ['physical draft ownership', b => { b.ll_dispatch_physical_pallet_lock[0].draft_token = 'OTHER-DRAFT'; b.ll_dispatch_physical_pallet_lock[0].draft_container_id = '991' }], + ['pool fixture', b => { b.ll_fleet_pool_order_link.push(row('ll_fleet_pool_order_link', { id: '999' })) }], +]) test(`baseline rejects ${name}`, () => { const value = baseline(); mutate(value); assert.throws(() => assertRollbackBaseline(value, scope)) }) +test('late dirty observer proves first completed transfer, both source writes, unchanged native roots and prior SHIP', () => { + const proof = assertMidTransactionObservation(midRows(), scope, baseline()) + assert.equal(proof.dirtyObservationOnly, true); assert.equal(proof.firstCustomerId, '101'); assert.equal(proof.sessionLockWaitSeconds, '50') + assert.doesNotThrow(() => assertTargetTransferUpdate(updateSql.replaceAll('=', ' = '), scope)) + assert.doesNotThrow(() => assertTargetTransferUpdate(updateSql.replace('customer_id=?', "customer_id='99'").replace('lastleg_order_id=?', 'lastleg_order_id=702'), scope)) +}) +for (const [name, mutate] of [ + ['foreign blocker', r => { r[1].blocker = '77' }], + ['missing wait', r => r.splice(1, 1)], + ['multiple waits', r => r.push({ ...r[1], requester: '13' })], + ['wrong target row', r => { r[1].lockId = '801' }], + ['wrong blocking row', r => { r[1].blockingLockId = '801' }], + ['wrong blocking transaction', r => { r[1].blockingTransactionId = '113' }], + ['unrelated transaction', r => { r[1].ownsRoot = 0 }], + ['unavailable session variable', r => { delete r[1].sessionLockWaitSeconds }], + ['global timeout stand-in', r => { delete r[1].sessionLockWaitSeconds; r[1].globalLockWaitSeconds = '50' }], + ['session timeout beyond lease', r => { r[1].sessionLockWaitSeconds = '120' }], + ['unavailable requesting SQL', r => { r[1].statement = null }], + ['early child lock', r => { r[1].statement = 'SELECT * FROM ll_outbound_order WHERE dispatch_order_id=201 FOR UPDATE' }], + ['different transfer UPDATE', r => { r[1].statement = updateSql.replace('lastleg_order_id=?', 'lastleg_order_id=701') }], + ['first outbound not written', r => { r.find(x => x.table === 'll_outbound_order' && x.row?.id === '901').row.status = 'WAIT_OUTBOUND' }], + ['first order not written', r => { r.find(x => x.table === 'll_order' && x.row?.id === '701').row.shipped_package_count = '0' }], + ['no first source history', r => { r.find(x => x.table === 'll_loading_order_status_history' && x.row?.order_id === '701').row.event_id = 'OTHER' }], + ['first transfer not written', r => { r.find(x => x.table === 'll_warehouse_transfer_order' && x.row?.id === '801').row.outbound_at = null }], + ['second transfer already changed', r => { r.find(x => x.table === 'll_warehouse_transfer_order' && x.row?.id === '802').row.status = 'OUTBOUNDED' }], + ['root changed too soon', r => { r.find(x => x.table === 'll_dispatch_order' && x.row).row.stage = 'DELIVERING' }], + ['physical lock released before late source update', r => { r.find(x => x.table === 'll_dispatch_physical_pallet_lock' && x.row).row.status = 'RELEASED' }], + ['permanent guard deleted in departure transaction', r => { r.splice(r.findIndex(x => x.table === 'll_physical_pallet_guard' && x.row), 1); r.find(x => x.collection === 'coverage' && x.table === 'll_physical_pallet_guard').count-- }], + ['permanent guard recreated in departure transaction', r => { r.find(x => x.table === 'll_physical_pallet_guard' && x.row).row.created_at = '2026-10-19 12:00:00.999' }], + ['permanent guard key replaced in departure transaction', r => { r.find(x => x.table === 'll_physical_pallet_guard' && x.row).row.warehouse_pallet_id = '991' }], + ['source contribution added before late source update', r => { const value = row('ll_dispatch_item_source', { id: '991', dispatch_order_id: '201' }); r.find(x => x.collection === 'coverage' && x.table === 'll_dispatch_item_source').count = 1; r.push({ collection: 'row', table: 'll_dispatch_item_source', row: value }) }], + ['prior SHIP changed', r => { r.find(x => x.table === 'll_inventory_ledger' && x.row).row.delta_packages = '0' }], +]) test(`rejects insufficient middle evidence: ${name}`, () => { const value = midRows(); mutate(value); assert.throws(() => assertMidTransactionObservation(value, scope, baseline())) }) +test('rollback compares every full field, not just business status or inventory totals', () => { + const before = baseline(), after = structuredClone(before) + const proof = assertRestoredSnapshot(before, after) + assert.equal(proof.beforeSha256, proof.afterSha256); assert.equal(proof.committedRead, true) + for (const table of tableNames.filter(t => after[t].length)) { + const changed = structuredClone(after); changed[table][0][Object.keys(changed[table][0]).at(-1)] = 'CHANGED' + assert.throws(() => assertRestoredSnapshot(before, changed), table) + } +}) +test('database conflict requires exact handler result and trace; timeout or precondition rejection fails', () => { + assert.doesNotThrow(() => assertDatabaseLockConflict(conflict())) + for (const wrong of [{ http: 0 }, { http: 500 }, { body: { code: 'S0500', message: 'error' } }, { body: { code: 'A0409', message: '请完成拣货' } }, { traceId: null }]) + assert.throws(() => assertDatabaseLockConflict({ ...conflict(), ...wrong })) +}) +test('single-attempt request keeps original key/version/body on the 60-second channel', async () => { + const body = { recordVersion: '11', idempotencyKey: 'ORIGINAL' }, calls = [] + const context = { request: () => { throw Error('30_SECOND_CHANNEL') }, requestWithBody: async (...args) => { calls.push(args); return { http: 0, body: null } } } + assert.equal((await requestRollbackDeparture(context, '/owned/depart', body, 'owned-token')).http, 0) + assert.deepEqual(calls, [['POST', '/owned/depart', body, 'owned-token']]); assert.equal(calls[0][2], body) +}) +test('plan has no network; non-TEST execution fails before any fetch', async () => { + const fetch = globalThis.fetch; globalThis.fetch = () => { throw Error('NETWORK_FORBIDDEN') } + try { + const plan = await runTripRollback({ execute: false }, { env: {} }) + assert.equal(plan.businessSqlWrites, false); assert.equal(plan.deployment, false); assert.match(plan.fault, /One native dispatch/) + await assert.rejects(runTripRollback({ execute: true, environment: 'TEST' }, { env: { CHONGCHONG_ENV: 'prod' } }), /TEST/) + } finally { globalThis.fetch = fetch } +}) +test('offline verifier preserves original command and requires all phases before caller can depart/sign', async () => { + const original = process.cwd(), temp = fs.mkdtempSync(join(tmpdir(), 'fleet-rollback-offline-')) + try { + process.chdir(temp); fs.mkdirSync('reports/fleet-cross-offline', { recursive: true }) + const calls = [], ready = { trip: { dispatches: [{ dispatchNo: request.dispatchNo }] }, dispatch: { dispatchNo: request.dispatchNo }, task: { recordVersion: '11' } } + const fixture = { ...request, pdaToken: 'synthetic-offline-token' }, body = { recordVersion: '11', idempotencyKey: request.commandKey, remark: request.marker } + const runtime = { imageId: request.expectedImage, rows: [{ collection: 'databaseRuntime', serverUuid: 'offline', databaseName: 'offline', performanceSchema: 1 }] } + const read = async (_env, _scope, mode) => { + calls.push(mode) + if (mode === 'RESOLVE') return { rows: resolution() } + if (mode === 'BASELINE' || mode === 'RESTORED') return { rows: snapshotRows(baseline()) } + if (mode === 'LEASE') return { rows: [{ collection: 'held', lockId: '802', connectionId: '11', customerId: '99' }, { released: true }] } + if (mode === 'LOCKS') return { rows: [lease()] } + if (mode === 'OBSERVE') return { rows: midRows() } + if (mode === 'RELEASED') return { rows: [] } + throw Error('UNEXPECTED_MODE') + } + const context = { marker: request.marker, requestWithBody: async (...args) => { calls.push(args); return conflict() } } + const proof = await createDepartureRollbackVerifier({}, { runId: 'offline' }, runtime, { read, wait: () => { throw Error('NO_REAL_WAIT') } }) + ({ context, fixture, ready, departPath: '/owned/depart', departBody: body, snapshot: async () => ready }) + assert.equal(proof.verified, true); assert.equal(proof.consistentSnapshot.fullFields, true) + assert.deepEqual(calls.filter(Array.isArray), [['POST', '/owned/depart', body, fixture.pdaToken]]) + assert.deepEqual(calls.filter(x => typeof x === 'string'), ['RESOLVE', 'BASELINE', 'LEASE', 'LOCKS', 'OBSERVE', 'RELEASED', 'RESTORED']) + assert.deepEqual(proof.command.body, body) + const saved = JSON.parse(fs.readFileSync('reports/fleet-cross-offline/rollback.json', 'utf8')) + assert.equal(saved.verified, true); assert.ok(saved.finishedAt) + const caller = fs.readFileSync(new URL('./admin-test-env-modules/fleet-cross-customer-acceptance.mjs', import.meta.url), 'utf8') + assert.ok(caller.indexOf('await verifyDepartureRollback(') < caller.indexOf("const departed = await request('POST', departPath, departBody")) + assert.match(caller, /Platform exact replay of PDA native departure/); assert.match(caller, /This customer independently signed/) + } finally { + process.chdir(original) + assert.ok(resolve(temp).startsWith(resolve(tmpdir()) + '\\') || resolve(temp).startsWith(resolve(tmpdir()) + '/')) + fs.rmSync(temp, { recursive: true, force: true }) + } +}) + + +test('23 full snapshots include batch-only permanent guards, empty ordinary contributions and both customer-free physical owners from actual DDL', () => { + const full = fullRollbackSnapshot(snapshotRows(baseline()), scope) + assert.equal(tableNames.length, 23); assert.equal(Object.keys(full).length, 23) + assert.equal(snapshotRows(baseline()).filter(row => row.collection === 'coverage').length, 23) + assert.equal(full.ll_dispatch_item_source.length, 0) + assert.deepEqual(full.ll_physical_pallet_guard.map(row => row.warehouse_pallet_id).sort(), ['401', '402']) + assert.deepEqual(ddlColumns('ll_physical_pallet_guard').map(column => [column.column, column.type, column.key]), [['warehouse_pallet_id', 'bigint', 'PRI'], ['created_at', 'datetime(3)', '']]) + assert.deepEqual(Object.keys(full.ll_physical_pallet_guard[0]).sort(), ['created_at', 'warehouse_pallet_id']) + assert.equal(full.ll_dispatch_physical_pallet_lock.length, 2) + assert.deepEqual(Object.keys(full.ll_dispatch_physical_pallet_lock[0]).sort(), ddlColumns('ll_dispatch_physical_pallet_lock').map(column => column.column).sort()) + assert.equal(Object.hasOwn(full.ll_dispatch_physical_pallet_lock[0], 'customer_id'), false) + assert.equal(ddlColumns('ll_dispatch_pallet_lock').some(column => column.column === 'active_warehouse_pallet_id'), false) + assert.ok(ddlColumns('ll_dispatch_pallet_lock').some(column => column.column === 'physical_lock_id')) + assert.ok(ddlColumns('ll_dispatch_pda_pick_order').some(column => column.column === 'dispatch_item_source_id')) + assert.doesNotThrow(() => assertRollbackBaseline(full, scope)) + const sql = rollbackSql(scope, 'BASELINE') + assert.match(sql, /FROM ll_dispatch_physical_pallet_lock WHERE dispatch_order_id=201 OR warehouse_pallet_id IN \(401,402\)/) + assert.match(sql, /FROM ll_dispatch_item_source WHERE dispatch_order_id=201 OR dispatch_item_id IN \(301,302\)/) + for (const mode of ['BASELINE', 'OBSERVE', 'RESTORED']) { + const guardQueries = rollbackSql(scope, mode).split(';').filter(query => query.includes('FROM ll_physical_pallet_guard')) + assert.equal(guardQueries.length, 2) + for (const query of guardQueries) assert.ok(query.endsWith('FROM ll_physical_pallet_guard WHERE warehouse_pallet_id IN (401,402)')) + assert.ok(guardQueries.some(query => query.includes('COUNT(*)'))) + assert.ok(guardQueries.some(query => query.includes("'created_at',CAST(`created_at` AS CHAR CHARACTER SET utf8mb4)"))) + } + assert.match(sql, /'dispatch_item_source_id',CAST\(`dispatch_item_source_id` AS CHAR CHARACTER SET utf8mb4\)/) +}) + +test('missing source-table zero coverage or partial new-table schema cannot masquerade as a full rollback snapshot', () => { + for (const table of ['ll_dispatch_item_source', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard']) { + const rows = snapshotRows(baseline()).filter(row => !(row.collection === 'coverage' && row.table === table)) + assert.throws(() => fullRollbackSnapshot(rows, scope), /EXACT_COVERAGE_REQUIRED/) + const resolvedRows = resolution().filter(row => !(row.collection === 'column' && row.table === table)) + assert.throws(() => resolveRollbackScope(request, resolvedRows), /COMPLETE_ACTUAL_SCHEMA_REQUIRED/) + } + const changed = snapshotRows(baseline()) + const physical = changed.find(row => row.table === 'll_dispatch_physical_pallet_lock' && row.row) + delete physical.row.released_at + assert.throws(() => fullRollbackSnapshot(changed, scope), /COMPLETE_FULL_FIELD_SNAPSHOT_REQUIRED/) +}) + +test('permanent guard schema cannot introduce customer, owner, state or TTL or change the original PK/time types', () => { + for (const mutate of [ + rows => { rows.find(row => row.table === 'll_physical_pallet_guard' && row.column === 'warehouse_pallet_id').key = '' }, + rows => { rows.find(row => row.table === 'll_physical_pallet_guard' && row.column === 'created_at').type = 'datetime' }, + ...['customer_id', 'owner', 'state', 'expires_at'].map(column => rows => rows.push({ collection: 'column', table: 'll_physical_pallet_guard', column, ordinal: 3, type: 'varchar(128)', key: '' })), + ]) { + const rows = resolution(); mutate(rows) + assert.throws(() => resolveRollbackScope(request, rows)) + } +}) +for (const fault of ['delete', 'recreate', 'created timestamp', 'key replacement']) test(`committed rollback rejects permanent guard ${fault}`, () => { + const before = baseline(), restored = structuredClone(before) + if (fault === 'delete') restored.ll_physical_pallet_guard.pop() + if (fault === 'recreate') { const old = restored.ll_physical_pallet_guard.shift(); restored.ll_physical_pallet_guard.push({ ...old, created_at: '2026-10-19 12:00:00.123' }) } + if (fault === 'created timestamp') restored.ll_physical_pallet_guard[0].created_at = '2026-10-19 11:00:00.999' + if (fault === 'key replacement') restored.ll_physical_pallet_guard[0].warehouse_pallet_id = '991' + assert.throws(() => assertRestoredSnapshot(before, restored), /FULL_FIELD_COMMITTED_ROLLBACK_SNAPSHOT_CHANGED/) }) diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index 4e698f3c7..d8bb15b80 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 13 }, "openapi": { - "documents": 72, + "documents": 100, "coveredServices": 11, "codeFirstServices": 2 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 1efe09648..89ab9fb46 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,7 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 }) - assert.equal(summary.openapi.documents, 72) + assert.equal(summary.openapi.documents, 100) for (const name of ['lastleg-exports', 'return-packing', 'fleet-capacity-offers']) { assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) } @@ -75,6 +75,8 @@ test('repository freezes the current topology and non-blocking aggregate summary assert.ok(summary.modules.every(row => !['gateway-overseas', 'event-bridge-service'].includes(row.module))) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/loading-evidence-v1.yaml')) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml')) + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/dispatch-evidence-v1.yaml')) + assert.ok(summary.openapi.files.includes('lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml')) assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml')) assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml')) assert.equal(baseline.openapi.documents, summary.openapi.documents) diff --git a/scripts/mixed-container-outbound-test-env.mjs b/scripts/mixed-container-outbound-test-env.mjs index 383edbff1..e5ac68416 100644 --- a/scripts/mixed-container-outbound-test-env.mjs +++ b/scripts/mixed-container-outbound-test-env.mjs @@ -1,49 +1,295 @@ -import { readFileSync, writeFileSync, existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' -import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' -import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery, verifyDepartureReadiness, + dispatchInventorySources, inventorySnapshot, dispatchStockConservation, inventorySourcesPresent, + relabelOutboundMarksMatch } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { f40CreateDispatchBody } from './admin-test-env-modules/phase5-root-fulfillment.mjs' -// Explicit retained fixture continuation, not a new fixture generator. -const [sourcePath, containerNo, label, outputPath] = process.argv.slice(2) -if (!sourcePath || !containerNo || !['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) || !outputPath) { - throw new Error('Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit test authorization required') +const LASTLEG = '/api/platform/v1/last-leg' +const DISPATCH = `${LASTLEG}/dispatches` +const PDA = '/api/pda/v1/last-leg/dispatches' +const MODULE = 'native-retained-outbound' +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Retained ID lost precision') + return value == null ? '' : String(value) } -const options = parseArguments([]) -validateExecutionOptions(options) -const context = new AcceptanceRun(options) -const source = JSON.parse(readFileSync(resolve(sourcePath), 'utf8')) -const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label) -if (!record || record.overseas?.plan?.containerNo !== containerNo || !record.overseas?.receiving?.receivingVerified) { - throw new Error('Fixture must be the explicitly selected, completed mixed-container receipt') +const validVersion = value => (typeof value === 'string' || typeof value === 'number' && Number.isSafeInteger(value)) && /^(0|[1-9]\d*)$/.test(String(value)) +const success = response => response?.http === 200 && String(response?.body?.code) === '200' +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const requireFact = (condition, message) => { if (!condition) throw Error(message) } +const sorted = values => [...new Set(values)].sort() +const redact = value => { + if (Array.isArray(value)) return value.map(redact) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value) + .filter(([name]) => !/password|token|authorization|base64|^bytes$|^content$/i.test(name)).map(([name, nested]) => [name, redact(nested)])) + return value } -const inboundCode = record.source.inboundCode -const customerId = record.overseas.plan.customerId -const output = resolve(outputPath) -const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { - marker: context.marker, containerNo, inboundCode, label, source: resolve(sourcePath), events: [], +export function saveRetainedReport(output, result) { + mkdirSync(dirname(output), { recursive: true }) + writeFileSync(`${output}.tmp`, `${JSON.stringify(redact({ ...result, updatedAt: new Date().toISOString() }), null, 2)}\n`) + renameSync(`${output}.tmp`, output) } -if (result.inboundCode !== inboundCode || result.containerNo !== containerNo || result.label !== label) throw new Error('Checkpoint scope mismatch') -context.marker = result.marker -const save = () => writeFileSync(output, JSON.stringify({ ...result, updatedAt: new Date().toISOString() }, null, 2)) -const base = '/api/platform/v1/last-leg' -async function api(method, path, body, token) { - const response = await context.requestOverseas(method, path, body, {}, token || context.overseasToken) - result.events.push({ method, path, http: response.http, code: response.body?.code, - message: response.body?.message, requestId: response.requestId, traceId: response.traceId }) - save() - if (response.http !== 200 || String(response.body?.code) !== '200') throw new Error(`${method} ${path}: ${response.body?.message}`) - return response.body.data +export function validateRetainedScope(result, scope) { + requireFact(result.schema === 'NATIVE_RETAINED_OUTBOUND_V1' && result.environment === 'TEST' + && result.marker === scope.marker && result.sourceSha256 === scope.sourceSha256 + && result.customerId === id(scope.customerId) && result.warehouseId === id(scope.warehouseId) + && same(result.inboundCodes, scope.inboundCodes), 'Retained native marker/source/customer/warehouse scope mismatch; legacy reports cannot resume') + requireFact(!result.requests?.some(row => ['SENDING', 'UNKNOWN'].includes(row.status)), + 'Unknown write outcome retained: reconcile the original command before further writes') } -let picker -try { - const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') - if (!login.body?.data?.overseasAccessToken) throw new Error('Platform login failed') - context.token = login.body.data.accessToken - context.overseasToken = login.body.data.overseasAccessToken - if (label === 'MIXED-DROPSHIP') { - const candidatePath = `${base}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}` +/** Every business write is saved before sending. Unknown results never generate another key. */ +export function installRetainedJournal(context, result, save) { + const request = context.request.bind(context), multipart = context.requestMultipart?.bind(context) + context.createdResources = result.resources ||= {} + context.createdResources.phase5OverseasFulfillment = result.fulfillment ||= {} + context.context ||= {} + context.context.phase5InventorySources = result.inventorySources || {} + context.checkpoint = async () => { result.inventorySources = context.context.phase5InventorySources; await save() } + result.requests ||= [] + let stopped = false + const execute = async (method, path, body, send, file) => { + if (method === 'GET' || path.startsWith('/api/auth/')) return send() + const cleanup = method === 'PUT' && /^\/api\/platform\/v1\/iam\/users\/[0-9]+\/status$/.test(path) && body?.status === 'DISABLED' + if (stopped && !cleanup) throw Error('Unknown write outcome: retained business writes stopped') + const entry = { method, path, ...(file ? { file } : { body: structuredClone(body) }), status: 'SENDING' } + result.requests.push(entry); await context.checkpoint() + let response + try { response = await send() } + catch (error) { stopped = true; entry.status = 'UNKNOWN'; await context.checkpoint(); throw error } + const missingNativeResult = success(response) && (path.startsWith(DISPATCH) || path.startsWith(`${LASTLEG}/fleet/`)) + && (response.body?.data == null || typeof response.body.data !== 'object') + const known = response?.http > 0 && response.http < 500 && response.body?.code != null && !missingNativeResult + Object.assign(entry, { status: known ? 'RESPONDED' : 'UNKNOWN', http: response?.http, + code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId, result: response?.body?.data }) + if (entry.status === 'UNKNOWN') stopped = true + await context.checkpoint() + if (stopped && !cleanup) throw Error('Unknown write outcome retained; no automatic retry or replacement command') + return response + } + context.request = (method, path, body, token, headers) => execute(method, path, body, () => request(method, path, body, token, headers)) + context.requestMultipart = (method, path, file, token, headers) => { + const bytes = Buffer.from(file.content) + return execute(method, path, undefined, () => multipart(method, path, file, token, headers), + { name: file.name, type: file.type, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }) + } + return context +} +export async function retainedApi(context, method, path, body, token) { + const response = await context.request(method, path, body, token) + const data = context.apiSuccess(MODULE, `${method} ${path}`, response, '', { deploymentBlocker: true }) + if (!success(response)) throw Error(`Retained request failed: ${method} ${path}; HTTP=${response?.http}; code=${response?.body?.code}`) + return data +} +function sourceKey(selection) { + if (selection.sourceType === 'INBOUND_PLAN') return `PLAN:${id(selection.planPalletId)}` + if (selection.sourceType === 'RELABEL') return `RELABEL:${selection.relabelNo}:${id(selection.warehousePalletId)}` + throw Error('Unsupported retained source selector') +} +export function validateNativeRetainedDispatch(native, scope) { + requireFact(native?.marker === scope.marker && native?.dispatchNo && native.createBody?.idempotencyKey + && id(native.createBody.warehouseId) === id(scope.warehouseId) && native.createBody.cargo?.length + && native.createResult?.dispatchNo === native.dispatchNo && native.createResult?.action === 'CREATE' + && native.createResult?.status === 'ACTIVE' && native.createResult?.stage === 'BEFORE_LOADING' + && validVersion(native.createResult.recordVersion) && native.detail?.dispatchNo === native.dispatchNo + && native.detail.status === 'ACTIVE' && validVersion(native.detail.recordVersion) + && Array.isArray(native.detail.orderDetails) && native.detail.orderDetails.length, + 'Retained dispatch requires native Create command/result/Detail and the original marker; dispatch number alone is insufficient') + const keys = native.createBody.cargo.map(sourceKey) + requireFact(new Set(keys).size === keys.length && same(sorted(keys), sorted(native.detail.orderDetails.map(row => row.sourceKey))) + && native.detail.orderDetails.every(row => id(row.customerId) === id(scope.customerId)), 'Retained native source/customer identity mismatch') + requireFact(native.createBody.cargo.every(row => /^[a-f\d]{64}$/i.test(row.sourceVersion || '') + && /^[1-9]\d*$/.test(id(row.warehousePalletId))), 'Retained selectors require original immutable source versions and physical pallet IDs') + return keys +} +async function ledger(context, scope, dispatchNo) { + const rows = []; let total + for (let page = 1; page <= 50; page++) { + const params = new URLSearchParams({ customerId: id(scope.customerId), businessType: 'SHIP', businessOrderNo: dispatchNo, + outboundOrderNo: dispatchNo, page: String(page), size: '200' }) + const data = await retainedApi(context, 'GET', `${LASTLEG}/inventory/ledger?${params}`) + requireFact(Array.isArray(data.records) && Number.isSafeInteger(Number(data.total)), 'Incomplete retained SHIP ledger') + total ??= Number(data.total); requireFact(Number(data.total) === total, 'Ledger snapshot changed during pagination') + rows.push(...data.records) + if (rows.length >= total) break + requireFact(data.records.length > 0, 'Truncated retained SHIP ledger') + } + requireFact(rows.length === total && new Set(rows.map(row => id(row.id))).size === rows.length + && rows.every(row => id(row.customerId) === id(scope.customerId) && row.businessType === 'SHIP' + && row.businessOrderNo === dispatchNo && row.outboundOrderNo === dispatchNo), 'Foreign or duplicated retained shipment ledger') + return rows.map(row => ({ ...row, id: id(row.id), customerId: id(row.customerId), overseasWarehouseId: id(row.overseasWarehouseId), + beforePackages: Number(row.beforePackages), deltaPackages: Number(row.deltaPackages), afterPackages: Number(row.afterPackages) })).sort((a, b) => a.id.localeCompare(b.id)) +} +const NATIVE_DEPS = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness, + saveReceiptAndComplete, finishFleetTrip, inventorySnapshot, dispatchInventorySources, dispatchStockConservation, ledger } +/** Continue only a proved native dispatch. All mutations remain real public API commands. */ +export async function runRetainedNativeDispatch(context, result, scope, pdaToken, overrides = {}) { + const deps = { ...NATIVE_DEPS, ...overrides } + const api = (method, path, body, token) => retainedApi(context, method, path, body, token) + validateRetainedScope(result, scope) + context.marker = scope.marker + let native = result.nativeDispatch + requireFact(native, 'No original native dispatch proof; create it from current authoritative candidates first') + const expectedSources = validateNativeRetainedDispatch(native, scope), dispatchNo = native.dispatchNo, path = `${DISPATCH}/${encodeURIComponent(dispatchNo)}` + result.dispatchNo = dispatchNo + let detail = await api('GET', path) + const command = await api('GET', `${path}/commands/${encodeURIComponent(native.createBody.idempotencyKey)}`) + requireFact(command.dispatchNo === dispatchNo && command.action === 'CREATE' + && String(command.recordVersion) === String(native.createResult.recordVersion) + && same(command.result, native.createResult.result), 'Original CREATE command is absent or differs from retained production receipt') + const items = await api('GET', `${path}/items`) + requireFact(detail.status === 'ACTIVE' && validVersion(detail.recordVersion) + && same(sorted(items.items?.map(row => row.sourceKey) || []), sorted(expectedSources)) + && items.items.every(row => id(row.customerId) === id(scope.customerId) && scope.inboundCodes.includes(row.inboundCode)) + && Number(detail.counts?.packageCount) === scope.expectedPackages, 'Live native cargo/customer/quantity drift') + const task = await api('GET', `${PDA}/${encodeURIComponent(dispatchNo)}`, undefined, pdaToken) + requireFact(task.dispatchNo === dispatchNo && id(task.warehouseId) === id(scope.warehouseId) + && Array.isArray(task.items) && task.items.length > 0 && (!scope.expectedPalletItems || task.items.length === scope.expectedPalletItems) + && task.items.every(row => id(row.customerId) === id(scope.customerId)) + && task.items.reduce((sum, row) => sum + Number(row.scheduledPackages), 0) === scope.expectedPackages, 'PDA warehouse/customer/item quantity differs from the original physical sources') + const bol = await context.requestBinary('GET', `${path}/prints/bol`) + requireFact(bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') && Buffer.from(bol.bytes || []).subarray(0, 5).toString() === '%PDF-', 'BOL is not a real PDF') + result.bol = { sha256: createHash('sha256').update(bol.bytes).digest('hex'), size: bol.bytes.length } + let trip = result.tripProof || (context.createdResources.fleetTrips || []).find(row => row.dispatchNo === dispatchNo) + if (detail.stage === 'BEFORE_LOADING') { + requireFact(!trip, 'Partly prepared resources already exist; retain the original commands instead of creating replacements') + trip = await deps.prepareFleetTrip(context, MODULE, dispatchNo, id(scope.warehouseId)); result.tripProof = trip + detail = await api('GET', path) + } + requireFact(trip?.preparation === 'VERIFIED' && trip.dispatchNo === dispatchNo && id(trip.warehouseId) === id(scope.warehouseId), 'Native resource/review proof is missing or incomplete') + result.tripProof = trip + if (detail.stage === 'LOADING') { + if (!result.inventory) { + requireFact(Number(task.counts?.pickedPackageCount) === 0, 'Cannot reconstruct a pre-scan inventory baseline after physical scanning') + const sources = deps.dispatchInventorySources(task, dispatchNo, id(scope.warehouseId)) + requireFact(sources, 'Missing real PDA source positions/balances') + result.inventory = { ...sources, + stockBefore: await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-before-pick', sources.sourceBalanceIds), + ledgerBefore: await deps.ledger(context, scope, dispatchNo) } + requireFact(result.inventory.stockBefore && inventorySourcesPresent(result.inventory.stockBefore, sources.sourcePositionBalances), 'Incomplete original pre-scan source inventory') + await context.checkpoint() + } + requireFact(await deps.pickAll(context, pdaToken, dispatchNo), 'PDA real picks failed') + result.inventory.stockAfter = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-pick', result.inventory.sourceBalanceIds) + result.inventory.ledgerAfter = await deps.ledger(context, scope, dispatchNo) + const conservation = deps.dispatchStockConservation(context, { ...result.inventory, customerId: id(scope.customerId), warehouseId: id(scope.warehouseId), + dispatchNo, expectedPackages: scope.expectedPackages }) + result.inventory.conservation = conservation.proof + requireFact(conservation.passed, 'Physical inventory/SHIP conservation failed') + await context.checkpoint() + try { await deps.confirmFleetLoading(context, MODULE, trip, pdaToken, result.loadingMeasurements || native.loadingMeasurements) } + catch (error) { + const field = ['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'].find(value => String(error?.message).includes(value)) + result.blocker = { stage: 'LOADING_COMPLETE', code: field ? 'ACTUAL_MEASUREMENT_REQUIRED' : 'LOADING_COMPLETE_NOT_VERIFIED', field: field || null } + await context.checkpoint(); throw error + } + detail = await api('GET', path) + } + requireFact(result.inventory?.conservation, 'No original scan quantity/ledger proof for this retained native dispatch') + const recovered = action => result.requests?.find(row => row.status === 'RESPONDED' && String(row.code) === '200' && row.result?.dispatchNo === dispatchNo && row.result?.action === action)?.result + if (!result.transfer) { + const saved = recovered('ARRANGE_DELIVERY') + if (saved) result.transfer = { data: saved, deliveries: saved.result.deliveries, outbounds: saved.result.outbounds } + else { + requireFact(detail.stage === 'AFTER_LOADING', 'Missing original arrangement command; do not borrow existing historical delivery rows') + result.transfer = await deps.arrangeDispatchDelivery(context, dispatchNo) + requireFact(result.transfer, 'Native arrangement failed') + } + await context.checkpoint() + } + const deliveries = result.transfer.deliveries, outbounds = result.transfer.outbounds + requireFact(Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length === 1 + && id(outbounds[0].customerId) === id(scope.customerId) && deliveries.every(row => id(row.customerId) === id(scope.customerId) + && row.outboundNo === outbounds[0].outboundNo) && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === scope.expectedPackages, + 'Native arrangement does not conserve the retained customer and physical quantity') + result.outboundNo = outbounds[0].outboundNo; result.deliveryNos = deliveries.map(row => row.deliveryNo) + if (!result.departure) result.departure = recovered('DEPART') + if (detail.stage === 'AFTER_LOADING') { + const ready = await deps.verifyDepartureReadiness(context, pdaToken, dispatchNo) + requireFact(ready && validVersion(ready.data.recordVersion), 'Native server DEPART gates unavailable') + result.departBody ||= { recordVersion: ready.data.recordVersion, idempotencyKey: `${scope.marker}-DEPART`.slice(0, 128), remark: `${scope.marker}-TEST` } + await context.checkpoint() + result.departure = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken) + const replay = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken) + requireFact(replay.replayed === true && String(replay.recordVersion) === String(result.departure.recordVersion) + && same(replay.result, result.departure.result), 'Departure original command replay drift') + result.departureReplay = replay + detail = await api('GET', path) + } + requireFact(result.departure?.stage === 'DELIVERING' && result.departure?.status === 'ACTIVE' + && Number(result.departure.result?.shippedPackages) === scope.expectedPackages + && same(sorted(result.departure.result?.deliveries?.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`) || []), + sorted(deliveries.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`))) + && ['DELIVERING', 'DELIVERED'].includes(detail.stage), 'Original native departure receipt missing or invalid') + result.inventory.stockAfterDepart = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-depart', result.inventory.sourceBalanceIds) + result.inventory.ledgerAfterDepart = await deps.ledger(context, scope, dispatchNo) + requireFact(same(result.inventory.stockAfter, result.inventory.stockAfterDepart) + && same(result.inventory.ledgerAfter, result.inventory.ledgerAfterDepart), 'Departure repeated SHIP or changed inventory') + result.outbound = await api('GET', `${LASTLEG}/outbound-orders/${encodeURIComponent(result.outboundNo)}`) + requireFact(result.outbound.outbound?.status === 'OUTBOUNDED' && id(result.outbound.outbound.customerId) === id(scope.customerId) + && result.outbound.outbound.outboundNo === result.outboundNo && result.outbound.outbound.dispatchNo === dispatchNo + && Number(result.outbound.outbound.packageCount) === scope.expectedPackages, 'Actual retained outbound is not complete') + if (scope.relabelCartonMarks) requireFact(relabelOutboundMarksMatch(result.outbound, scope), 'New relabel carton marks were lost, duplicated or replaced with old marks') + result.deliveries ||= [] + for (const [index, delivery] of deliveries.entries()) { + const deliveryPath = `${LASTLEG}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}?customerId=${encodeURIComponent(scope.customerId)}` + const current = await api('GET', deliveryPath) + requireFact(id(current.order?.customerId) === id(scope.customerId) && current.order.outboundNo === result.outboundNo, 'Delivery customer/outbound identity drift') + if (current.order.status !== 'DELIVERED') { + requireFact(current.order.status === 'DELIVERING', 'Actual native departure must advance delivery before POD') + requireFact(await deps.saveReceiptAndComplete(context, delivery, id(scope.customerId), index), 'POD did not automatically complete delivery') + } else { + const original = result.requests.find(row => row.method === 'PUT' && row.path === `${LASTLEG}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}/documents?customerId=${encodeURIComponent(scope.customerId)}` && row.status === 'RESPONDED' && row.result?.categories) + requireFact(original, 'Already signed delivery has no original retained POD save command') + const replay = await api('PUT', original.path, original.body) + requireFact(replay.replayed === true && String(replay.version) === String(original.result.version), 'POD original-command replay drift') + } + const signed = await api('GET', deliveryPath) + requireFact(signed.order?.status === 'DELIVERED' && signed.order.signedAt, 'Real signedAt/POD completion missing') + result.deliveries[index] = signed; await context.checkpoint() + } + for (let attempt = 0; attempt < 30; attempt++) { + detail = await api('GET', path) + if (detail.stage === 'DELIVERED') break + await new Promise(resolve => setTimeout(resolve, 1000)) + } + requireFact(detail.status === 'ACTIVE' && detail.stage === 'DELIVERED', 'All normal signatures did not aggregate native DELIVERED') + result.nativeCompleted = detail + if (!trip.closure) result.tripClosure = await deps.finishFleetTrip(context, MODULE, trip) + else requireFact(trip.closure.status === 'VERIFIED', 'Retained resource closure is incomplete; preserve original release commands') + result.finalStatus = 'PASSED'; await context.checkpoint(); return result +} + +export async function mainMixed(args = process.argv.slice(2)) { + const [sourcePath, containerNo, label, outputPath] = args + requireFact(sourcePath && containerNo && ['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) && outputPath, + 'Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit TEST authorization required') + const options = parseArguments([]); options.modules = []; validateExecutionOptions(options) + requireFact(options.environment === 'test', 'Retained mixed outbound only supports explicit TEST') + const sourceBytes = readFileSync(resolve(sourcePath)), source = JSON.parse(sourceBytes) + const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label) + requireFact(source.environment === 'test' && source.marker && record?.overseas?.plan?.containerNo === containerNo + && record.overseas?.receiving?.receivingVerified, 'Explicit current TEST receipt source is required') + const inboundCode = record.source.inboundCode, customerId = id(record.overseas.plan.customerId), warehouseId = id(record.overseas.warehouseId) + const output = resolve(outputPath), scope = { marker: `${source.marker}-${label}`, customerId, warehouseId, inboundCodes: [inboundCode], expectedPackages: 4, + sourceSha256: createHash('sha256').update(sourceBytes).digest('hex') } + const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { schema: 'NATIVE_RETAINED_OUTBOUND_V1', environment: 'TEST', + ...scope, containerNo, inboundCode, label, source: resolve(sourcePath), requests: [] } + validateRetainedScope(result, scope) + const context = installRetainedJournal(new AcceptanceRun(options), result, () => saveRetainedReport(output, result)) + context.marker = scope.marker + const api = (method, path, body, token) => retainedApi(context, method, path, body, token) + let picker + try { + const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + requireFact(success(login) && login.body?.data?.accessToken, 'Unified platform login failed') + context.token = login.body.data.accessToken + if (label === 'MIXED-DROPSHIP') { + const candidatePath = `${LASTLEG}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}` if (!result.task) { const candidates = await api('GET', candidatePath) const selected = candidates.filter(row => row.inboundCode === inboundCode) @@ -57,17 +303,17 @@ try { result.selected = selected result.inputs = inputs result.createBody = { recipient: `${context.marker} TEST ONLY`, items: inputs, remark: `${context.marker}-test-only`, idempotencyKey: `${context.marker}-OUTBOUND` } - save() - result.task = await api('POST', `${base}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody) - save() + await context.checkpoint() + result.task = await api('POST', `${LASTLEG}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody) + await context.checkpoint() } - const before = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`) + const before = await api('GET', `${LASTLEG}/outbound-orders/${result.task.outboundNo}`) if (before.outbound.status !== 'OUTBOUNDED') { const access = await resolvePickerAccess(context) picker = access ? await createPicker(context, 9, record.overseas.warehouseId, access) : null if (!picker?.token) throw new Error('PDA operator unavailable') result.pickerMembershipId = picker.membershipId - save() + await context.checkpoint() const path = `/api/pda/v1/last-leg/outbound-tasks/${result.task.taskNo}` const detail = await api('GET', path, undefined, picker.token) for (const [index, candidate] of result.selected.entries()) { @@ -80,95 +326,66 @@ try { } result.completedTask = await api('POST', `${path}/complete`, { idempotencyKey: `${context.marker}-COMPLETE` }, picker.token) } - result.outbound = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`) + result.outbound = await api('GET', `${LASTLEG}/outbound-orders/${result.task.outboundNo}`) result.remainingCandidates = (await api('GET', candidatePath)).filter(row => row.inboundCode === inboundCode) result.finalStatus = result.outbound.outbound.status === 'OUTBOUNDED' && result.remainingCandidates.length === 0 ? 'OUTBOUND_PASSED_DELIVERY_NOT_SUPPORTED' : 'OUTBOUND_FAILED' - } else { - const candidates = await api('GET', `${base}/dispatches/candidates?sourceStage=RECEIVED&orderNo=${encodeURIComponent(record.overseas.summary.orderNo)}`) - const selected = candidates.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo) - if (!result.dispatchNo) { - if (selected.reduce((sum, row) => sum + Number(row.packageCount), 0) !== 4 || selected.some(row => row.fbaWarehouseCode !== 'ABE4')) throw new Error('Live FBA candidate quantity or destination changed') - // The caller must retain the checkpoint after every mutation; do not retry creation without checking live candidates. - const dispatch = await api('POST', `${base}/dispatches/pre-arrange`, { planPalletIds: selected.map(row => row.planPalletId) }) - result.dispatchNo = dispatch.dispatchNo - save() - } - const dispatchPath = `${base}/dispatches/${encodeURIComponent(result.dispatchNo)}` - let dispatch = await api('GET', dispatchPath) - if (!result.transfer) { - await api('PUT', dispatchPath, { vehiclePlate: `TEST-${context.marker.slice(-8)}`, driverName: 'HTTP测试司机', - driverPhone: '19900000000', carrierName: 'HTTP测试车队', estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString() }) - result.transfer = await api('POST', `${dispatchPath}/transfer-delivery`, { - idempotencyKey: `${context.marker}-TRANSFER`, plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), - deliveryMethod: 'TRUCK_PALLET', carrierName: 'HTTP测试车队', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', - vehicleType: 'TRUCK', vehicleSize: '15托', driverName: 'HTTP测试司机', driverPhone: '19900000000', - vehiclePlate: `TEST-${context.marker.slice(-8)}`, appointmentAccount: 'SELF', remark: `${context.marker}-test-only`, - fees: [{ feeType: 'DELIVERY_FEE', feeName: '测试派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 1, remark: `${context.marker}-test-only` }], - }) - save() - } - dispatch = await api('GET', dispatchPath) - if (dispatch.status !== 'DEPARTED') { - const access = await resolvePickerAccess(context) - picker = access ? await createPicker(context, 8, record.overseas.warehouseId, access) : null - if (!picker?.token) throw new Error('PDA operator unavailable') - result.pickerMembershipId = picker.membershipId - save() - const tripTask = await api('GET', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}`, undefined, picker.token) - if (!tripTask.tripNo) throw new Error('Retained dispatch must be assigned to a qualified fleet trip before picking; prepare through the current fleet API') - const trip = await api('GET', `${base}/fleet/trips/${encodeURIComponent(tripTask.tripNo)}`) - if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed') - const tripVersion = await confirmFleetLoading(context, 'mixed-container-outbound', { tripNo: tripTask.tripNo, stops: trip.trip.stops }) - result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`, - { recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: `${context.marker}-test-only` }, picker.token) - if (result.departure.status !== 'IN_TRANSIT' || !result.departure.dispatches?.some(item => item.dispatchNo === result.dispatchNo)) throw new Error('Whole-trip departure result missing retained dispatch') - save() - } - const deliveries = result.transfer.deliveries - if (!Array.isArray(deliveries) || deliveries.length !== 1 || Number(result.transfer.packageCount) !== 4) throw new Error('Unexpected transfer quantities') - result.deliveries = [] - for (const [index, delivery] of deliveries.entries()) { - const path = `${base}/delivery-orders/${delivery.deliveryNo}?customerId=${customerId}` - const detail = await api('GET', path) - if (detail.order.status !== 'DELIVERED') { - if (detail.order.status !== 'DELIVERING') await api('PUT', `${base}/delivery-orders/${delivery.deliveryNo}/status?customerId=${customerId}`, - { status: 'DELIVERING', expectedVersion: detail.order.version, remark: `${context.marker}-test-only` }) - if (!await saveReceiptAndComplete(context, delivery, customerId, index)) throw new Error('Receipt signature failed') + + } else { + if (!result.nativeDispatch) { + const savedCreate = result.requests.find(row => row.method === 'POST' && row.path === DISPATCH && row.status === 'RESPONDED' + && String(row.code) === '200' && row.result?.action === 'CREATE') + if (savedCreate) { + requireFact(result.createBody && same(savedCreate.body, result.createBody), 'Original native CREATE body must remain exact') + const detail = await api('GET', `${DISPATCH}/${encodeURIComponent(savedCreate.result.dispatchNo)}`) + result.nativeDispatch = { marker: scope.marker, dispatchNo: savedCreate.result.dispatchNo, createBody: savedCreate.body, createResult: savedCreate.result, detail, + loadingMeasurements: record.loadingMeasurements } + await context.checkpoint() + } + } + if (!result.nativeDispatch) { + const page = await api('GET', `${DISPATCH}/candidates?warehouseId=${warehouseId}`) + const selected = page?.candidates?.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo + && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId) + requireFact(page?.complete === true && selected?.length && selected.reduce((sum, row) => sum + Number(row.availablePackages), 0) === 4 + && selected.every(row => row.destination?.warehouseCode === 'ABE4'), 'Live native FBA source/destination/quantity changed') + result.createBody ||= f40CreateDispatchBody(scope.marker, warehouseId, customerId, selected) + await context.checkpoint() + const created = await api('POST', DISPATCH, result.createBody) + const detail = await api('GET', `${DISPATCH}/${encodeURIComponent(created.dispatchNo)}`) + result.nativeDispatch = { marker: scope.marker, dispatchNo: created.dispatchNo, createBody: result.createBody, createResult: created, detail, + loadingMeasurements: record.loadingMeasurements } + await context.checkpoint() + } + const access = await resolvePickerAccess(context) + picker = access ? await createPicker(context, 8, warehouseId, access) : null + requireFact(picker?.token, 'PDA operator unavailable') + result.pickerMembershipId = picker.membershipId; await context.checkpoint() + await runRetainedNativeDispatch(context, result, scope, picker.token) + for (let attempt = 0; attempt < 30; attempt++) { + const data = await api('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${encodeURIComponent(inboundCode)}&page=1&size=20`) + result.domesticInbound = data.records?.find(row => row.inboundCode === inboundCode) + if (result.domesticInbound?.processStatus === 'DELIVERED') break + await new Promise(resolve => setTimeout(resolve, 1000)) + } + requireFact(result.domesticInbound?.orderId, 'Missing original domestic source order') + result.domesticOrder = (await api('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`))?.summary + requireFact(result.domesticInbound.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED', 'Domestic source completion callback did not converge') } - result.deliveries.push(await api('GET', path)) - save() - } - result.outbound = await api('GET', `${base}/outbound-orders/${result.transfer.outboundNo}`) - for (let attempt = 0; attempt < 30; attempt++) { - const response = await context.request('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${inboundCode}&page=1&size=20`) - if (String(response.body?.code) !== '200') throw new Error('Domestic inbound query failed') - result.domesticInbound = response.body.data.records.find(row => row.inboundCode === inboundCode) - if (result.domesticInbound?.processStatus === 'DELIVERED') break - await new Promise(resolve => setTimeout(resolve, 1000)) - } - if (!result.domesticInbound?.orderId) throw new Error('Missing domestic source order') - const order = await context.request('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`) - result.domesticOrder = order.body?.data?.summary - result.finalStatus = result.domesticInbound?.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED' - && result.outbound?.outbound?.status === 'OUTBOUNDED' ? 'PASSED' : 'STATE_CALLBACK_FAILED' - } -} catch (error) { - result.error = error.message - result.finalStatus = 'BLOCKED' - process.exitCode = 1 -} finally { - try { - await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8) } catch (error) { - result.cleanupError = error.message - result.finalStatus = 'CLEANUP_FAILED' + result.finalStatus = 'BLOCKED'; result.failedStage = result.nativeDispatch?.dispatchNo ? 'NATIVE_FULFILLMENT' : 'SOURCE_PREPARATION' + process.exitCode = 1 + } finally { + try { await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8) } + catch { result.finalStatus = 'CLEANUP_FAILED' } + finally { if (picker) picker.token = null } + result.checks = context.steps + if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED' + if (result.finalStatus !== 'PASSED') process.exitCode = 1 + await context.checkpoint() + console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, + dispatchNo: result.dispatchNo, outboundNo: result.outboundNo || result.task?.outboundNo, deliveryNos: result.deliveryNos, report: output })) } - result.checks = context.steps - if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED' - if (result.finalStatus !== 'PASSED') process.exitCode = 1 - save() - console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, error: result.error, - dispatchNo: result.dispatchNo, outboundNo: result.transfer?.outboundNo || result.task?.outboundNo, - deliveryNos: result.transfer?.deliveries?.map(row => row.deliveryNo), report: output })) + return result } +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainMixed() diff --git a/scripts/native-retained-outbound-contract.test.mjs b/scripts/native-retained-outbound-contract.test.mjs new file mode 100644 index 000000000..8d7b6f1df --- /dev/null +++ b/scripts/native-retained-outbound-contract.test.mjs @@ -0,0 +1,175 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installRetainedJournal, validateRetainedScope, validateNativeRetainedDispatch, runRetainedNativeDispatch, + saveRetainedReport } from './mixed-container-outbound-test-env.mjs' +import { retainedRelabelSource } from './overseas-relabel-fulfillment-acceptance.mjs' + +const BASE = '/api/platform/v1/last-leg/dispatches' +const DOCS = '/api/platform/v1/last-leg/delivery-orders/DL-1/documents?customerId=100' +const ok = data => ({ http: 200, body: { code: '200', data }, requestId: 'REQ-1', traceId: 'TRACE-1' }) +const scope = () => ({ marker: 'RUN-1', sourceSha256: 'a'.repeat(64), customerId: '100', warehouseId: '80', inboundCodes: ['IN-1'], expectedPackages: 4 }) +function nativeProof() { + return { marker: 'RUN-1', customerId: '100', dispatchNo: 'DS-1', + createBody: { warehouseId: '80', idempotencyKey: 'RUN-1-CREATE', cargo: [{ sourceType: 'INBOUND_PLAN', planPalletId: '9007199254740993', + warehousePalletId: '9007199254740994', sourceVersion: 'b'.repeat(64) }] }, + createResult: { dispatchNo: 'DS-1', action: 'CREATE', recordVersion: '0', status: 'ACTIVE', stage: 'BEFORE_LOADING', result: { dispatchNo: 'DS-1', changedItemIds: ['9007199254740995'] } }, + detail: { dispatchNo: 'DS-1', recordVersion: '0', status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: { packageCount: '4' }, + orderDetails: [{ sourceKey: 'PLAN:9007199254740993', customerId: '100', inboundCode: 'IN-1' }] } } +} +const resultFor = () => ({ schema: 'NATIVE_RETAINED_OUTBOUND_V1', environment: 'TEST', ...scope(), nativeDispatch: nativeProof(), requests: [] }) + +test('retained scope rejects legacy data, foreign marker/source identity and unresolved original writes before continuation', () => { + assert.throws(() => validateRetainedScope({ dispatchNo: 'DS-1' }, scope()), /legacy reports/) + for (const mutate of [value => { value.marker = 'HISTORY' }, value => { value.sourceSha256 = 'other' }, + value => { value.customerId = '200' }, value => { value.warehouseId = '81' }, + value => { value.requests.push({ status: 'UNKNOWN' }) }, value => { value.requests.push({ status: 'SENDING' }) }]) { + const value = resultFor(); mutate(value); assert.throws(() => validateRetainedScope(value, scope())) + } + assert.deepEqual(validateNativeRetainedDispatch(nativeProof(), scope()), ['PLAN:9007199254740993']) + for (const mutate of [value => { delete value.createBody }, value => { value.createResult.recordVersion = 9007199254740992 }, + value => { value.createResult.action = 'EDIT' }, value => { value.detail.orderDetails[0].customerId = '200' }, + value => { value.createBody.cargo[0].sourceVersion = 'unknown' }, value => { value.detail.orderDetails[0].sourceKey = 'PLAN:OTHER' }]) { + const value = nativeProof(); mutate(value); assert.throws(() => validateNativeRetainedDispatch(value, scope())) + } +}) + +test('journal saves exact original intent before network, blocks unknown/malformed native successes, and refuses replacement commands', async () => { + for (const response of [{ http: 0 }, { http: 503, body: { code: 'A0500' } }, ok(null), { http: 200, body: null }]) { + const result = resultFor(), snapshots = [], calls = [] + const context = installRetainedJournal({ createdResources: {}, context: {}, + async request(method, path, body) { calls.push({ method, path, body }); assert.equal(snapshots.at(-1).requests.at(-1).status, 'SENDING'); return response }, + }, result, async () => { snapshots.push(structuredClone(result)) }) + const body = { recordVersion: '0', idempotencyKey: 'ORIGINAL' } + await assert.rejects(context.request('POST', `${BASE}/DS-1/depart`, body), /Unknown write/) + assert.equal(result.requests[0].status, 'UNKNOWN') + assert.deepEqual(result.requests[0].body, body) + await assert.rejects(context.request('POST', `${BASE}/DS-1/depart`, { ...body, idempotencyKey: 'NEW' }), /stopped/) + assert.equal(calls.length, 1) + assert.throws(() => validateRetainedScope(result, scope()), /Unknown write/) + } +}) + +test('retained report stores file hashes and redacts credentials and binary bytes', () => { + const directory = mkdtempSync(join(tmpdir(), 'native-retained-contract-')) + try { + const path = join(directory, 'report.json') + saveRetainedReport(path, { marker: 'RUN-1', password: 'SECRET-PASSWORD', accessToken: 'SECRET-TOKEN', + requests: [{ file: { sha256: 'a'.repeat(64), content: 'SECRET-BYTES' } }] }) + const text = readFileSync(path, 'utf8') + assert.doesNotMatch(text, /SECRET-/) + assert.ok(text.includes('a'.repeat(64))) + } finally { rmSync(directory, { recursive: true, force: true }) } +}) + +function stock(after) { + const quantity = after ? 0 : 4 + return [{ id: '1', totalPackages: quantity, balance: { id: '1', totalPackages: quantity }, + positions: [{ id: '11', balanceId: '1', customerId: '100', overseasWarehouseId: '80', inboundCode: 'IN-1', palletNo: 'P-1', + totalPackages: quantity, availablePackages: quantity, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }], + globalGroups: [{ totalPackages: quantity }] }] +} +function nativeRunFixture() { + const result = resultFor(), events = [], calls = [] + let stage = 'BEFORE_LOADING', picked = false, signed = false, departure, pod + result.loadingMeasurements = { environment: 'TEST', marker: 'RUN-1', dispatchNo: 'DS-1', pallets: [] } + const delivery = { customerId: '100', deliveryNo: 'DL-1', outboundNo: 'OB-1', packageCount: '4', destination: { key: 'FBA:ABE4', warehouseCode: 'ABE4' } } + const context = { token: 'PLATFORM-IN-MEMORY', marker: 'RUN-1', createdResources: {}, context: {}, + apiSuccess(_module, _label, response) { return response.body?.data }, assert(_m, _l, _r, passed) { assert.equal(passed, true) }, + async requestBinary() { return { http: 200, contentType: 'application/pdf', bytes: Buffer.from('%PDF-1.4 TEST') } }, + async request(method, path, body, token) { + calls.push({ method, path, body: structuredClone(body), token }) + if (method === 'GET' && path.includes('/commands/')) return ok(result.nativeDispatch.createResult) + if (method === 'GET' && path.endsWith('/items')) return ok({ items: [{ sourceKey: 'PLAN:9007199254740993', customerId: '100', inboundCode: 'IN-1' }] }) + if (method === 'GET' && path.startsWith('/api/pda/')) return ok({ dispatchNo: 'DS-1', warehouseId: '80', counts: { pickedPackageCount: picked ? '4' : '0' }, + items: [{ customerId: '100', scheduledPackages: '4', positions: [{ positionId: '11', balanceId: '1' }] }] }) + if (method === 'GET' && path === `${BASE}/DS-1`) return ok({ dispatchNo: 'DS-1', status: 'ACTIVE', stage, recordVersion: '9007199254740997', counts: { packageCount: '4' } }) + if (method === 'POST' && path.endsWith('/depart')) { + events.push('depart'); assert.equal(body.recordVersion, '9007199254740996'); assert.equal(token, 'PDA-IN-MEMORY') + if (departure) { assert.deepEqual(body, result.departBody); return ok({ ...departure, replayed: true }) } + stage = 'DELIVERING' + departure = { dispatchNo: 'DS-1', action: 'DEPART', status: 'ACTIVE', stage, recordVersion: '9007199254740997', result: { shippedPackages: '4', deliveries: [delivery] } } + return ok(departure) + } + if (method === 'GET' && path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED', customerId: '100', outboundNo: 'OB-1', dispatchNo: 'DS-1', packageCount: 4 }, pallets: [{ cartonMarks: 'NEW-1,NEW-2,NEW-3,NEW-4' }] }) + if (method === 'GET' && path.includes('/delivery-orders/')) return ok({ order: { customerId: '100', outboundNo: 'OB-1', status: signed ? 'DELIVERED' : 'DELIVERING', signedAt: signed ? '2026-10-19T01:00:00' : null } }) + if (method === 'PUT' && path === DOCS) { + events.push('pod') + if (pod) return ok({ ...pod, replayed: true }) + signed = true; stage = 'DELIVERED'; pod = { version: '9007199254740998', categories: [{ category: 'RECEIPT' }] } + return ok(pod) + } + throw Error(`Unexpected offline ${method} ${path}`) + }, + } + installRetainedJournal(context, result, async () => { events.push('checkpoint') }) + const deps = { + async prepareFleetTrip(_context, _module, dispatchNo, warehouseId) { events.push('prepare'); stage = 'LOADING'; return { dispatchNo, warehouseId, tripNo: 'TR-1', preparation: 'VERIFIED' } }, + async pickAll(_context, token) { events.push('pick'); assert.equal(token, 'PDA-IN-MEMORY'); picked = true; return true }, + async confirmFleetLoading(_context, _module, _proof, token, measurements) { + events.push('loading'); assert.equal(token, 'PDA-IN-MEMORY'); assert.equal(measurements, result.loadingMeasurements) + if (!measurements) throw Error('Actual palletSpaces measurement is missing') + stage = 'AFTER_LOADING'; return '9007199254740995' + }, + async arrangeDispatchDelivery() { events.push('arrange'); return { data: { result: {} }, deliveries: [delivery], outbounds: [{ customerId: '100', outboundNo: 'OB-1' }] } }, + async verifyDepartureReadiness() { events.push('ready'); return { data: { recordVersion: '9007199254740996' } } }, + async saveReceiptAndComplete(current) { await current.request('PUT', DOCS, { expectedVersion: '0', idempotencyKey: 'ORIGINAL-POD' }); return { order: { status: 'DELIVERED' } } }, + async finishFleetTrip(_context, _module, proof) { events.push('finish'); proof.closure = { status: 'VERIFIED' }; return proof.closure }, + async inventorySnapshot() { return stock(picked) }, + async ledger() { return picked ? [{ id: '81', customerId: '100', overseasWarehouseId: '80', businessType: 'SHIP', businessOrderNo: 'DS-1', + outboundOrderNo: 'DS-1', palletNo: 'P-1', beforePackages: 4, deltaPackages: -4, afterPackages: 0 }] : [] }, + } + return { context, result, deps, events, calls } +} + +test('retained native execution preserves real ordering, token/measurement scope, current zero-safe version, SHIP once, POD and same-run resume', async () => { + const f = nativeRunFixture() + await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps) + assert.equal(f.result.finalStatus, 'PASSED') + assert.deepEqual(f.events.filter(value => value !== 'checkpoint'), ['prepare', 'pick', 'loading', 'arrange', 'ready', 'depart', 'depart', 'pod', 'finish']) + assert.equal(f.result.inventory.conservation.totals.ledgerShipped, 4) + assert.deepEqual(f.result.inventory.ledgerAfter, f.result.inventory.ledgerAfterDepart) + assert.equal(f.result.departBody.recordVersion, '9007199254740996') + const originalDepartCount = f.calls.filter(row => row.method === 'POST').length + await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps) + assert.equal(f.calls.filter(row => row.method === 'POST').length, originalDepartCount) + assert.equal(f.events.filter(value => value === 'pod').length, 2) + assert.equal(f.events.filter(value => value === 'pick').length, 1) + assert.equal(f.calls.some(row => /pre-arrange|transfer-delivery/.test(row.path)), false) +}) + +test('missing real measurements stops after scanned quantity proof and before arrangement, departure and POD', async () => { + const f = nativeRunFixture(); delete f.result.loadingMeasurements + await assert.rejects(runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps), /measurement is missing/) + assert.equal(f.events.includes('arrange'), false); assert.equal(f.events.includes('depart'), false); assert.equal(f.events.includes('pod'), false) + assert.equal(f.result.inventory.conservation.totals.ledgerShipped, 4) + assert.equal(f.result.blocker.code, 'ACTUAL_MEASUREMENT_REQUIRED') + assert.equal(f.result.blocker.field, 'palletSpaces') +}) + +test('relabel input retains original/new mark evidence and cannot upgrade legacy report or a bare business number into native success', () => { + assert.throws(() => retainedRelabelSource({ marker: 'RUN-1', scenarios: [{ name: 'SPLIT-TWO-TARGETS', dispatchNo: 'DS-1' }] }, '100'), /native source report/) + const native = nativeProof() + native.createBody.cargo[0] = { sourceType: 'RELABEL', relabelNo: 'RL-1', warehousePalletId: '9007199254740994', sourceVersion: 'b'.repeat(64) } + native.detail.orderDetails[0].sourceKey = 'RELABEL:RL-1:9007199254740994' + const source = { environment: 'TEST', marker: 'RUN-1', scenarios: [{ name: 'SPLIT-TWO-TARGETS', relabelNo: 'RL-1', nativeDispatch: native, + relabelCartonMarks: ['NEW-1', 'NEW-2', 'NEW-3', 'NEW-4'], sourceCartonMarks: ['OLD-1', 'OLD-2', 'OLD-3', 'OLD-4'] }] } + assert.equal(retainedRelabelSource(source, '100', 'DS-1').scope.expectedPackages, 4) + const missing = structuredClone(source); delete missing.scenarios[0].sourceCartonMarks + assert.throws(() => retainedRelabelSource(missing, '100', 'DS-1'), /original\/new/) + assert.throws(() => retainedRelabelSource(source, '200', 'DS-1'), /native source report/) + assert.throws(() => retainedRelabelSource(source, '100', 'DS-HISTORY'), /native source report/) +}) + +test('both live entries use unified Auth and native helper without fixed physical measurements or an implicit historical delivery fallback', () => { + const mixed = readFileSync(new URL('./mixed-container-outbound-test-env.mjs', import.meta.url), 'utf8') + const relabel = readFileSync(new URL('./overseas-relabel-fulfillment-acceptance.mjs', import.meta.url), 'utf8') + for (const source of [mixed, relabel]) assert.doesNotMatch(source, /overseasAccessToken|ADMIN_OVERSEAS_GATEWAY|requestOverseas|READY_TO_DEPART|tripVersion|controlledPalletMeasurements/) + assert.match(mixed, /confirmFleetLoading\(context, MODULE, trip, pdaToken, result.loadingMeasurements \|\| native.loadingMeasurements\)/) + assert.match(mixed, /\/prints\/bol/) + assert.match(mixed, /OUTBOUND_PASSED_DELIVERY_NOT_SUPPORTED/) + assert.match(relabel, /runRetainedNativeDispatch/) + assert.doesNotMatch(relabel, /existingDeliveries|\/api\/merchant\/v1\/last-leg\/delivery-orders/) +}) diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index b23f8a9c1..e04da064b 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -1,177 +1,83 @@ -import { readFile, writeFile } from 'node:fs/promises' -import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { saveRetainedReport, validateRetainedScope, validateNativeRetainedDispatch, + installRetainedJournal, retainedApi, runRetainedNativeDispatch } from './mixed-container-outbound-test-env.mjs' -const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' -const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' -const sourceReport = process.env.OVERSEAS_ACCEPTANCE_SOURCE -const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO -const directRelabelNo = process.env.OVERSEAS_RELABEL_NO -const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json' -const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID -const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN -const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN -const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN -const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` -if (!directPlatformToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing platform credentials or token') -if (!directMerchantToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing merchant token or platform credentials') -if (!directPdaToken && (!process.env.PDA_TEST_USERNAME || !process.env.PDA_TEST_PASSWORD)) throw new Error('Missing PDA credentials or token') -if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id') +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Relabel ID lost precision') + return value == null ? '' : String(value) +} +const requireFact = (condition, message) => { if (!condition) throw Error(message) } -function id(value) { return value == null ? '' : String(value) } -function key(value) { return `${marker}-${value}`.slice(0, 200) } -function assert(value, message) { if (!value) throw new Error(message) } -async function api(base, token, method, path, body) { - const response = await fetch(`${base}${path}`, { method, - headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, - body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) }) - const text = await response.text(); let value - try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } - if (!response.ok || String(value?.code) !== '200') { - throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) - } - return value.data +/** A dispatch number or legacy pre-arrange report cannot establish current source ownership. */ +export function retainedRelabelSource(source, customerId, dispatchNo) { + const scenario = source?.scenarios?.find(value => value.name === 'SPLIT-TWO-TARGETS') + const native = scenario?.nativeDispatch + requireFact(source?.environment === 'TEST' && source.marker && native?.marker === source.marker + && scenario.relabelNo && native.customerId === id(customerId) + && (!dispatchNo || native.dispatchNo === dispatchNo), 'Relabel continuation requires this run native source report/customer/dispatch; old reports or a direct number cannot resume') + const inboundCodes = [...new Set((native.detail?.orderDetails || []).map(row => row.inboundCode).filter(Boolean))] + const expectedPackages = Number(native.detail?.counts?.packageCount) + requireFact(inboundCodes.length > 0 && Number.isSafeInteger(expectedPackages) && expectedPackages > 0 + && native.createBody?.cargo?.every(row => row.sourceType === 'RELABEL' && row.relabelNo === scenario.relabelNo), + 'Relabel native cargo does not belong to the original changed-carton sources') + const newMarks = scenario.relabelCartonMarks, oldMarks = scenario.sourceCartonMarks + requireFact(Array.isArray(newMarks) && newMarks.length === expectedPackages && newMarks.every(mark => typeof mark === 'string' && mark.trim()) + && new Set(newMarks).size === newMarks.length && Array.isArray(oldMarks) && oldMarks.length > 0 + && oldMarks.every(mark => typeof mark === 'string' && mark.trim()) && oldMarks.every(mark => !newMarks.includes(mark)), + 'Relabel source must retain original/new unique carton mark evidence; cannot infer marks from live final inventory') + const scope = { marker: native.marker, customerId: id(customerId), warehouseId: id(native.createBody.warehouseId), + inboundCodes, expectedPackages, expectedPalletItems: scenario.partialRelabel ? 1 : 2, relabelCartonMarks: newMarks, sourceCartonMarks: oldMarks } + validateNativeRetainedDispatch(native, scope) + return { scenario, native, scope } } -const source = sourceReport ? JSON.parse(await readFile(sourceReport, 'utf8')) : null -const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TARGETS') - || { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' } -assert(scenario?.dispatchNo, 'Dispatch not found in source report') -const dispatchNo = scenario.dispatchNo -const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { - loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD }) -const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { - loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD }) -const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST', - `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` }) -const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) -const platformToken = directPlatformToken || platformLogin.overseasAccessToken -const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken -const pdaToken = directPdaToken || pdaLogin.overseasAccessToken - -let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) -let alreadyDeparted = task.status === 'DEPARTED' -assert(alreadyDeparted || task.tripNo, 'Prepare and bind a qualified fleet trip before running retained dispatch fulfillment') -const existingPage = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`) -const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo) -const existingOutboundNos = new Set(existingDeliveries.map((value) => value.outboundNo).filter(Boolean)) -assert(existingOutboundNos.size <= 1, 'Dispatch is linked to multiple outbound orders') -const transfer = existingDeliveries.length > 0 ? { - outboundNo: [...existingOutboundNos][0], - deliveries: existingDeliveries.map((value) => ({ - deliveryNo: value.deliveryNo, deliveryStatus: value.status, - destinationWarehouseId: value.destinationWarehouseId, - destinationWarehouseCode: value.destinationWarehouseCode, - destinationWarehouseName: value.destinationWarehouseName, - palletCount: value.palletCount, packageCount: value.packageCount, - })), - replayed: true, -} : await api(overseas, merchantToken, 'POST', - `/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, { - idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), - deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', - pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托', - appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', - direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], - }) -const transferDeliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] -assert(transfer.outboundNo && transferDeliveries.length > 0, - 'Transfer did not create outbound and delivery') -assert(transferDeliveries.length === 1, - `Expected one destination delivery, got ${transferDeliveries.length}`) -const deliveryNo = transferDeliveries[0].deliveryNo - -const expectedPdaItems = scenario.partialRelabel ? 1 : 2 -assert(Array.isArray(task.items) && task.items.length === expectedPdaItems, - `Expected ${expectedPdaItems} PDA pallet items`) -if (!alreadyDeparted) { - for (const [itemIndex, item] of task.items.entries()) { - let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) - for (const [positionIndex, position] of item.positions.entries()) { - if (remaining <= 0) break - const packages = Math.min(remaining, Number(position.availablePackages)) - if (packages <= 0) continue - await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { - positionId: id(position.id), packages, productQuantity: packages, - idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, - }) - remaining -= packages +export async function mainRelabel(env = process.env) { + const sourcePath = env.OVERSEAS_ACCEPTANCE_SOURCE + requireFact(sourcePath && env.OVERSEAS_TEST_CUSTOMER_ID, 'Explicit native source report and customer are required; dispatch number alone is insufficient') + const sourceBytes = readFileSync(resolve(sourcePath)), source = JSON.parse(sourceBytes) + const { scenario, native, scope } = retainedRelabelSource(source, env.OVERSEAS_TEST_CUSTOMER_ID, env.OVERSEAS_DISPATCH_NO) + if (env.OVERSEAS_RELABEL_NO) requireFact(env.OVERSEAS_RELABEL_NO === scenario.relabelNo, 'Explicit relabel number differs from original source') + scope.sourceSha256 = createHash('sha256').update(sourceBytes).digest('hex') + const output = resolve(env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json') + const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { schema: 'NATIVE_RETAINED_OUTBOUND_V1', environment: 'TEST', + ...scope, sourceReport: resolve(sourcePath), relabelNo: scenario.relabelNo, nativeDispatch: native, requests: [] } + validateRetainedScope(result, scope) + const options = parseArguments([]); options.modules = []; options.output = output; validateExecutionOptions(options) + requireFact(options.environment === 'test', 'Relabel native continuation only supports explicit TEST') + const context = installRetainedJournal(new AcceptanceRun(options), result, () => saveRetainedReport(output, result)) + context.marker = scope.marker + const api = (method, path, body, token) => retainedApi(context, method, path, body, token) + try { + const platform = await api('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + requireFact(platform.accessToken, 'Unified platform accessToken is missing') + context.token = platform.accessToken + requireFact(env.PDA_TEST_ACCESS_TOKEN || options.pdaUsername && options.pdaPassword, 'Explicit current PDA token or complete credentials are required') + const pdaToken = env.PDA_TEST_ACCESS_TOKEN || (await api('POST', '/api/auth/login', { + loginId: options.pdaUsername, password: options.pdaPassword }, '')).accessToken + requireFact(pdaToken, 'Unified PDA credentials or explicit current token are required') + await runRetainedNativeDispatch(context, result, scope, pdaToken) + result.visits = [] + for (const deliveryNo of result.deliveryNos) { + const query = new URLSearchParams({ page: '1', size: '200', customerId: scope.customerId, keyword: deliveryNo }) + const visits = await api('GET', `/api/platform/v1/last-leg/customer-visits?${query}`) + const visit = visits.records?.find(row => row.deliveryNo === deliveryNo) + requireFact(visit?.status === 'PENDING' && id(visit.customerId) === scope.customerId, 'Real signature did not create the original customer visit') + result.visits.push({ id: id(visit.id), deliveryNo, status: visit.status }) } - assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) + result.passed = true; result.scope = 'OVERSEAS_LASTLEG_ONLY' + } catch (error) { + result.passed = false; result.finalStatus = 'BLOCKED'; result.failedStage = 'NATIVE_RELABEL_FULFILLMENT'; process.exitCode = 1 + } finally { + result.checks = context.steps + if (context.steps.some(step => step.passed === false)) { result.passed = false; result.finalStatus = 'CHECK_FAILED'; process.exitCode = 1 } + await context.checkpoint() + console.log(JSON.stringify({ marker: result.marker, dispatchNo: result.dispatchNo, outboundNo: result.outboundNo, + deliveryNos: result.deliveryNos, passed: result.passed === true, finalStatus: result.finalStatus, report: output })) } - task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) - alreadyDeparted = task.status === 'DEPARTED' + return result } -if (!alreadyDeparted && task.status !== 'COMPLETED') { - const pickingCompleted = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) - assert(pickingCompleted.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') - task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) -} -assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking') -if (!alreadyDeparted) { - const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(task.tripNo)}` - const trip = await api(overseas, platformToken, 'GET', tripPath) - const loading = await api(overseas, platformToken, 'GET', `${tripPath}/loading`) - assert(!loading.blockReason, `Trip not ready: ${loading.blockReason}`) - const confirmed = await api(overseas, platformToken, 'POST', `${tripPath}/loading`, { - recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, - pallets: controlledPalletMeasurements(loading.cargo, trip.trip.stops), reason: '受控测试测量,每实托100kg/1m³/1托位', - }) - const departed = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, - { recordVersion: confirmed.recordVersion, idempotencyKey: key('DEPART').slice(0,128), remark: marker }) - assert(departed.status === 'IN_TRANSIT' && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), 'Whole-trip departure missing dispatch') -} -const departedDispatch = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`) -assert(departedDispatch.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') - -const outbound = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) -assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED') -let delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) -if (delivery.order.status === 'WAIT_DELIVERY') { - const delivering = await api(overseas, platformToken, 'PUT', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, { - status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, - }) - assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') - delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) -} -if (delivery.order.status !== 'DELIVERED') { - assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`) - const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}` - const documents = await api(overseas, platformToken, 'GET', documentsPath) - const form = new FormData() - form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }), - `${marker}-${deliveryNo}-receipt.txt`) - const receipt = await api(overseas, platformToken, 'POST', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form) - const existingBindings = (documents.categories || []).map((category) => ({ - category: category.category, - fileIds: (category.files || []).map((file) => id(file.fileId)), - })).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0) - await api(overseas, platformToken, 'PUT', documentsPath, { - expectedVersion: Number(documents.version), idempotencyKey: key('SAVE-RECEIPT'), - bolNo: documents.bolNo || null, - documents: [...existingBindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }], - }) - delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) -} -assert(delivery.order.status === 'DELIVERED', 'Receipt did not automatically complete delivery') -const visits = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/customer-visits?page=1&size=200&customerId=${customerId}&keyword=${encodeURIComponent(deliveryNo)}`) -const visit = (visits.records || []).find((value) => value.deliveryNo === deliveryNo) -assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit') - -const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, - dispatchNo, dispatchStatus: departedDispatch.status, outboundNo: transfer.outboundNo, - outboundStatus: outbound.outbound.status, deliveryNo, - deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departedDispatch.shippedPackageCount, - passed: true, finishedAt: new Date().toISOString() } -await writeFile(output, JSON.stringify(report, null, 2), 'utf8') -console.log(JSON.stringify(report, null, 2)) +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainRelabel() diff --git a/scripts/overseas-relabel-real-acceptance.mjs b/scripts/overseas-relabel-real-acceptance.mjs index 329754dc4..6ff22c002 100644 --- a/scripts/overseas-relabel-real-acceptance.mjs +++ b/scripts/overseas-relabel-real-acceptance.mjs @@ -1,44 +1,275 @@ -import { writeFile } from 'node:fs/promises' +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { installRetainedJournal, retainedApi, saveRetainedReport } from './mixed-container-outbound-test-env.mjs' -const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' -const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' -const platformUser = process.env.ADMIN_TEST_USERNAME -const platformPassword = process.env.ADMIN_TEST_PASSWORD -const pdaUser = process.env.PDA_TEST_USERNAME -const pdaPassword = process.env.PDA_TEST_PASSWORD -const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID -const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID -const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN -const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN -const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN -const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json' -const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` -const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01') -if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount') - -for (const [name, value] of Object.entries({ customerId, warehouseId })) { - if (!value) throw new Error(`Missing environment variable: ${name}`) +const MODULE = 'overseas-relabel-producer' +const DISPATCH = '/api/platform/v1/last-leg/dispatches' +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Relabel ID lost precision') + return value == null ? '' : String(value) } -if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Error('Missing platform credentials or token') -if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials') -if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token') - -const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false } -function key(value) { return `${marker}-${value}`.slice(0, 200) } -function id(value) { return value == null ? '' : String(value) } -async function api(base, token, method, path, body, headers = {}) { - const response = await fetch(`${base}${path}`, { - method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers }, - body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), - }) - const text = await response.text() - let value - try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } - if (!response.ok || String(value?.code) !== '200') { - throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) +const exactVersion = value => { + if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw Error('Authoritative relabel version is missing or imprecise') + return value +} +const quantity = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : null +const sorted = values => [...new Set(values)].sort() +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const canonical = value => JSON.stringify(normalize(value)) +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize) + return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value +} +function requireFact(condition, message) { if (!condition) throw Error(message) } +function producerResponsePresent(path, data) { + if (data == null || typeof data !== 'object') return false + if (path === DISPATCH) return data.dispatchNo && data.action === 'CREATE' && data.recordVersion != null && data.result?.dispatchNo === data.dispatchNo + if (path.endsWith('/photos/files')) return /^[1-9]\d*$/.test(id(data.fileId)) + if (path.endsWith('/pallets/resolve')) return data.palletCode && /^[1-9]\d*$/.test(id(data.palletId)) && data.totalPackages != null + if (path.endsWith('/relabel-pallet-label')) return data.palletCode && /^[1-9]\d*$/.test(id(data.palletId)) + if (path.includes('/relabel-tasks/')) return data.relabelNo && data.version != null && Array.isArray(data.items) + if (path.includes('/relabel-applications')) return data.application?.relabelNo && Array.isArray(data.items) + return true +} +export function installRelabelJournal(context, report, save) { + installRetainedJournal(context, report, save) + const request = context.request.bind(context), upload = context.requestMultipart.bind(context) + const verify = async (method, path, send) => { + if (method !== 'GET' && report.requests.some(row => ['SENDING', 'UNKNOWN'].includes(row.status))) throw Error('Unknown producer write outcome: stop with original commands') + const response = await send() + if (method !== 'GET' && response.http === 200 && String(response.body?.code) === '200' + && !producerResponsePresent(path, response.body?.data)) { + const entry = report.requests.at(-1) + if (entry) entry.status = 'UNKNOWN' + await context.checkpoint(); throw Error('Unknown producer write result: native business response missing') + } + return response } - return value.data + context.request = (method, path, body, token, headers) => verify(method, path, () => request(method, path, body, token, headers)) + context.requestMultipart = (method, path, file, token, headers) => verify(method, path, () => upload(method, path, file, token, headers)) + return context +} +function validateLoadingEvidence(value, marker, dispatchNo, targets) { + const physicalIds = sorted(targets.map(row => id(row.palletId))) + requireFact(value?.environment === 'TEST' && value.marker === marker && typeof value.dispatchNo === 'string' && value.dispatchNo.trim() && value.dispatchNo === dispatchNo && Array.isArray(value.pallets) + && same(sorted(value.pallets.map(row => id(row.warehousePalletId))), physicalIds) + && value.pallets.length === physicalIds.length, 'Loading evidence belongs to another marker/dispatch/physical pallet') + for (const row of value.pallets) { + requireFact(Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0 && Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0, + 'Actual pallet spaces and physical stop index are required') + for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) { + requireFact(/^(0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number(row[field]) > 0 + && row.evidence?.some(e => e.actual === true && e.field === field && e.sourceType && e.sourceBusinessNo + && Number.isFinite(Date.parse(e.observedAt)) && Number(e.value) === Number(row[field])), 'Actual physical loading field evidence is missing') + } + } + return value +} +export function selectExplicitRelabelSource(candidates, config) { + requireFact(config.sourceBalanceId && config.sourcePositionId, 'Explicit source balance and position are required; no historical inventory fallback') + const matches = candidates.filter(row => id(row.inventoryBalanceId) === id(config.sourceBalanceId) + && id(row.overseasWarehouseId) === id(config.warehouseId) + && (!config.inboundCode || row.inboundCode === config.inboundCode) + && row.positions?.some(position => id(position.positionId) === id(config.sourcePositionId))) + requireFact(matches.length === 1, 'Explicit relabel source is absent or ambiguous in this customer warehouse') + const source = matches[0], positions = source.positions.filter(row => id(row.positionId) === id(config.sourcePositionId)) + requireFact(positions.length === 1 && String(source.inboundOrderNo || '').startsWith('DS-') && source.destinationFbaWarehouseCode + && quantity(source.availablePackages) >= 5 && quantity(positions[0].availablePackages) >= 5, 'Explicit source requires original DS inventory and five real available cartons') + return { source, sourcePosition: positions[0] } +} +function scenarioCargo(scenario, rows, customerId, warehouseId) { + const targets = sorted(scenario.targets.map(target => id(target.palletId))) + const expected = scenario.items.reduce((sum, item) => sum + Number(item.notifiedPackageCount), 0) + requireFact(rows.length === targets.length && same(sorted(rows.map(row => id(row.selection?.warehousePalletId))), targets) + && rows.reduce((sum, row) => sum + Number(row.availablePackages), 0) === expected, 'Native relabel candidates do not exactly cover original targets and carton quantity') + const cargo = rows.map(row => { + const selection = row.selection + requireFact(selection?.sourceType === 'RELABEL' && selection.relabelNo === scenario.relabelNo + && selection.planPalletId == null && selection.returnReceiptId == null + && /^[1-9]\d*$/.test(id(selection.warehousePalletId)) + && row.sourceKey === `RELABEL:${scenario.relabelNo}:${id(selection.warehousePalletId)}` + && /^[a-f\d]{64}$/i.test(row.sourceVersion || '') && row.sourceVersion === selection.sourceVersion + && id(row.customerId) === id(customerId) && id(row.warehouseId) === id(warehouseId) + && quantity(row.availablePackages) > 0 && row.destination?.type === 'FBA' + && row.destination.warehouseCode === scenario.destinationFbaWarehouseCode + && Array.isArray(row.blockReasons) && !row.blockReasons.length && Array.isArray(row.appointmentBatches) && !row.appointmentBatches.length, + 'Incomplete native RELABEL selector, warehouse/customer/destination or source version') + requireFact(!Object.hasOwn(row, 'cartonMarks') && !Object.hasOwn(row, 'relabelCartonMarks'), 'Native candidates must not expose legacy carton mark projections') + return { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: scenario.relabelNo, + warehousePalletId: id(selection.warehousePalletId), sourceVersion: selection.sourceVersion } + }) + return cargo +} + +/** The complete server group is the selection unit, including the whole application closure. */ +export function nativeRelabelCreateBody(marker, scenarios, rows, customerId, warehouseId) { + requireFact(Array.isArray(scenarios) && scenarios.length > 0 && rows.length > 0 + && new Set(scenarios.map(s => s.relabelNo)).size === scenarios.length, 'Owned relabel scenarios are missing or duplicate') + const group = rows[0].selectionGroup + requireFact(typeof group?.groupKey === 'string' && group.groupKey.trim() && typeof group.groupVersion === 'string' && group.groupVersion.trim() + && group.complete === true && Array.isArray(group.blockReasons) && !group.blockReasons.length + && Array.isArray(group.sources) && group.sources.length && Array.isArray(group.pallets) && group.pallets.length, + 'Authoritative complete selectionGroup and groupVersion are required') + requireFact(rows.every(row => canonical(row.selectionGroup) === canonical(group)), 'Selection group version or membership drift') + requireFact(new Set(rows.map(row => row.sourceKey)).size === rows.length && group.sources.length === rows.length + && new Set(group.sources.map(source => source.sourceKey)).size === group.sources.length + && same(sorted(group.sources.map(source => source.sourceKey)), sorted(rows.map(row => row.sourceKey))), 'Selection group logical sources are incomplete or duplicate') + const cargo = scenarios.flatMap(scenario => scenarioCargo(scenario, rows.filter(row => row.selection?.relabelNo === scenario.relabelNo), customerId, warehouseId)) + requireFact(cargo.length === rows.length, 'Selection group contains an unknown relabel business member') + for (const source of group.sources) { + const row = rows.find(value => value.sourceKey === source.sourceKey), scenario = scenarios.find(value => value.relabelNo === source.sourceBusinessNo) + requireFact(scenario?.relabelVerified === true && row && source.sourceBusinessType && id(source.customerId) === id(customerId) + && source.sourceBusinessNo === row.selection.relabelNo && canonical(source.selection) === canonical(row.selection) + && quantity(source.packages) > 0 && quantity(source.packages) === quantity(row.availablePackages), 'Selection group business, selector version or source quantity drift') + } + requireFact(new Set(group.pallets.map(p => id(p.warehousePalletId))).size === group.pallets.length + && same(sorted(group.pallets.map(p => id(p.warehousePalletId))), sorted(cargo.map(c => id(c.warehousePalletId)))), 'Selection group physical pallet closure is incomplete or duplicate') + for (const pallet of group.pallets) { + const sources = group.sources.filter(source => id(source.selection.warehousePalletId) === id(pallet.warehousePalletId)) + requireFact(/^[1-9]\d*$/.test(id(pallet.warehousePalletId)) && pallet.palletNo && sources.length + && Array.isArray(pallet.sourceKeys) && pallet.sourceKeys.length === sources.length + && same(sorted(pallet.sourceKeys), sorted(sources.map(source => source.sourceKey))) + && quantity(pallet.packageCount) === sources.reduce((sum, source) => sum + quantity(source.packages), 0) + && rows.filter(row => id(row.selection.warehousePalletId) === id(pallet.warehousePalletId)).every(row => row.palletNo === pallet.palletNo), + 'Selection group physical source contributions or carton quantity drift') + } + return { idempotencyKey: createHash('sha256').update(`${marker}\n${group.groupKey}\n${group.groupVersion}\nCREATE`).digest('hex'), warehouseId: id(warehouseId), + vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${marker}-${scenarios.map(s => s.name).join('+')}`, cargo, batchAllocations: [] } +} + +export function nativeRelabelGroups(scenarios, rows, customerId, warehouseId) { + const groups = new Map() + for (const scenario of scenarios) { + requireFact(scenario.relabelVerified === true && scenario.relabelCartonMarks?.length === scenario.expectedCartons + && new Set(scenario.relabelCartonMarks).size === scenario.relabelCartonMarks.length + && scenario.relabelCartonMarks.every(mark => typeof mark === 'string' && mark.trim() && !scenario.sourceCartonMarks.includes(mark)), 'Original/new relabel carton mark evidence is incomplete or overlaps') + const candidates = rows.filter(row => row.selection?.sourceType === 'RELABEL' && row.selection.relabelNo === scenario.relabelNo) + scenarioCargo(scenario, candidates, customerId, warehouseId) + requireFact(candidates.every(row => row.selectionGroup?.groupKey) && new Set(candidates.map(row => row.selectionGroup.groupKey)).size === 1, + 'Whole relabel application must belong to one authoritative complete selection group') + const key = candidates[0].selectionGroup.groupKey + if (!groups.has(key)) groups.set(key, { scenarios: [], selectionGroup: candidates[0].selectionGroup }) + groups.get(key).scenarios.push(scenario) + scenario.dispatchCandidates = candidates + } + const physicalOwners = new Map(), result = [] + for (const entry of groups.values()) { + const keys = entry.selectionGroup.sources?.map(source => source.sourceKey) || [] + entry.candidates = rows.filter(row => keys.includes(row.sourceKey)) + entry.createBody = nativeRelabelCreateBody('PREFLIGHT', entry.scenarios, entry.candidates, customerId, warehouseId) + for (const pallet of entry.selectionGroup.pallets) { + const key = id(pallet.warehousePalletId) + requireFact(!physicalOwners.has(key), 'One physical pallet cannot be split across selection groups') + physicalOwners.set(key, entry.selectionGroup.groupKey) + } + result.push(entry) + } + return result +} + +// Compare physical values once, retaining per-scenario raw receipts without adding whole-pallet weights. +export function relabelGroupMeasurements(scenarios, group) { + const values = new Map(), fields = ['palletCode', 'packages', 'stockVersion', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm'] + for (const scenario of scenarios) for (const measured of scenario.physicalMeasurements || []) { + const palletId = id(measured.palletId), pallet = group.pallets.find(p => id(p.warehousePalletId) === palletId) + if (!pallet) continue + requireFact(measured.palletCode === pallet.palletNo && quantity(measured.packages) === quantity(pallet.packageCount), 'Physical measurement does not cover the complete shared pallet') + const old = values.get(palletId) + if (old) requireFact(fields.every(field => canonical(old[field]) === canonical(measured[field])), 'Shared physical measurement versions or values conflict') + else values.set(palletId, structuredClone(measured)) + } + return [...values.values()] +} + +function groupLoadingEvidence(config, report, scenarios, group, dispatchNo) { + const targets = group.pallets.map(p => ({ palletId: id(p.warehousePalletId) })), inputs = scenarios.map(s => config.loadingEvidenceByScenario?.[s.name]).filter(Boolean) + if (!inputs.length) return undefined + const validated = inputs.map(value => validateLoadingEvidence(value, report.marker, dispatchNo || value.dispatchNo, targets)) + const physical = value => [...value.pallets].sort((a, b) => id(a.warehousePalletId).localeCompare(id(b.warehousePalletId))).map(p => ({ warehousePalletId: id(p.warehousePalletId), + palletSpaces: p.palletSpaces, weightKg: String(Number(p.weightKg)), volumeCbm: String(Number(p.volumeCbm)), deliveryIndex: p.deliveryIndex })) + requireFact(validated.every(value => value.dispatchNo === validated[0].dispatchNo && canonical(physical(value)) === canonical(physical(validated[0]))), 'Shared scenario loading receipts must name the same dispatch and identical physical measurements') + return structuredClone(validated[0]) +} + +/** Read the complete candidate universe and validate every owned group before the first CREATE. */ +export async function publishNativeRelabelDispatches(context, report, config) { + const scenarios = report.scenarios.filter(row => row.operationType === 'CARTON_AND_SKU') + report.blockers ||= [] + const block = (scenario, code, evidence) => { + scenario.dispatchStatus = 'BLOCKED'; scenario.nativeDispatchBlocker = { code, ...evidence } + report.blockers.push({ scenario: scenario.name, code, ...evidence }) + context.addCoverageGap?.(MODULE, scenario.name, 'Native relabel dispatch', code, 'physical-source-blocked', { deploymentBlocker: true }) + context.addStep?.(MODULE, `${scenario.name} native dispatch blocked`, null, false, code, { deploymentBlocker: true }) + } + const page = await retainedApi(context, 'GET', `${DISPATCH}/candidates?${new URLSearchParams({ warehouseId: id(config.warehouseId), sourceStage: 'RELABEL' })}`) + requireFact(page?.complete === true && Array.isArray(page.candidates), 'Native candidate list is incomplete') + for (const scenario of scenarios) { + scenario.sourceAfter = Number((await retainedApi(context, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: report.sourcePosition.palletNo }, config.pdaToken)).totalPackages) + scenario.targetPallets = scenario.targets.map(target => ({ palletId: id(target.palletId), palletCode: target.palletCode })) + scenario.physicalMeasurements = await retainedApi(context, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-measurements', { relabelNo: scenario.relabelNo }, config.pdaToken) + await context.checkpoint() + } + let groups + try { + groups = nativeRelabelGroups(scenarios, page.candidates, config.customerId, config.warehouseId) + for (const group of groups) { + group.physicalMeasurements = relabelGroupMeasurements(group.scenarios, group.selectionGroup) + groupLoadingEvidence(config, report, group.scenarios, group.selectionGroup) + } + } catch (error) { + report.nativeSelectionFailure = { message: error.message, candidates: page.candidates } + for (const scenario of scenarios) block(scenario, 'NATIVE_SELECTION_GROUP_NOT_VERIFIED', { reason: error.message }) + await context.checkpoint(); throw error + } + for (const group of groups) { + const members = group.scenarios + try { + const createBody = nativeRelabelCreateBody(report.marker, members, group.candidates, config.customerId, config.warehouseId) + const native = { marker: report.marker, customerId: id(config.customerId), warehouseId: id(config.warehouseId), createBody, physicalMeasurements: group.physicalMeasurements } + for (const scenario of members) { + scenario.nativeDispatch = native; scenario.nativeCreateBody = createBody; scenario.selectionGroup = group.selectionGroup + scenario.sourceContributions = group.selectionGroup.sources.filter(source => source.sourceBusinessNo === scenario.relabelNo) + } + await context.checkpoint() + const createResult = await retainedApi(context, 'POST', DISPATCH, createBody) + Object.assign(native, { createResult, dispatchNo: createResult?.dispatchNo }); await context.checkpoint() + requireFact(createResult?.action === 'CREATE' && createResult.dispatchNo && createResult.status === 'ACTIVE' + && createResult.stage === 'BEFORE_LOADING', 'Native CREATE did not return original active vehicle identity') + exactVersion(createResult.recordVersion) + const replay = await retainedApi(context, 'POST', DISPATCH, createBody) + native.createReplay = replay; await context.checkpoint() + requireFact(replay.replayed === true && replay.dispatchNo === createResult.dispatchNo + && String(replay.recordVersion) === String(createResult.recordVersion) && same(replay.result, createResult.result), 'Native CREATE replay changed original result') + const detail = await retainedApi(context, 'GET', `${DISPATCH}/${encodeURIComponent(createResult.dispatchNo)}`) + const sourceKeys = group.candidates.map(row => row.sourceKey) + requireFact(detail?.dispatchNo === createResult.dispatchNo && detail.status === 'ACTIVE' && detail.stage === 'BEFORE_LOADING' + && quantity(detail.counts?.packageCount) === group.selectionGroup.sources.reduce((sum, source) => sum + quantity(source.packages), 0) + && quantity(detail.counts?.palletCount) === group.selectionGroup.pallets.length + && id(exactVersion(detail.recordVersion)) === id(createResult.recordVersion) + && detail.orderDetails?.length >= sourceKeys.length + && same(sorted(detail.orderDetails?.map(row => row.sourceKey) || []), sorted(sourceKeys)) + && detail.orderDetails.every(row => group.selectionGroup.sources.some(source => source.sourceKey === row.sourceKey && id(source.customerId) === id(row.customerId) + && source.sourceBusinessNo === row.sourceBusinessNo && source.sourceBusinessType === row.sourceBusinessType)), 'Native Detail source/business/customer/carton identity drift') + native.detail = detail + const measurements = groupLoadingEvidence(config, report, members, group.selectionGroup, createResult.dispatchNo) + if (measurements) native.loadingMeasurements = measurements + for (const scenario of members) { + scenario.dispatchNo = createResult.dispatchNo; scenario.dispatchStatus = detail.status + if (!measurements) block(scenario, 'ACTUAL_LOADING_MEASUREMENT_REQUIRED', { fields: ['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'], + dispatchNo: createResult.dispatchNo, evidenceKind: 'PHYSICAL_MEASUREMENTS_NOT_FABRICATED' }) + } + await context.checkpoint() + } catch (error) { + if (report.requests?.some(row => ['SENDING', 'UNKNOWN'].includes(row.status))) throw error + for (const scenario of members) block(scenario, 'NATIVE_DISPATCH_NOT_VERIFIED', { candidates: scenario.dispatchCandidates, sourceBeforePackages: scenario.sourceBefore, + sourceAfterPackages: scenario.sourceAfter, reason: error.message, evidenceKind: 'RETAINED_ORIGINAL_REQUEST_AND_RESPONSE' }) + await context.checkpoint() + } + } + return scenarios } async function poll(action, predicate, attempts = 40) { for (let index = 0; index < attempts; index += 1) { @@ -50,183 +281,226 @@ async function poll(action, predicate, attempts = 40) { } function assert(condition, message) { if (!condition) throw new Error(message) } -const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword }) -const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword }) -const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST', - `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` }) -const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) -const platformToken = directPlatformToken || platformLogin.overseasAccessToken -const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken -const pdaToken = directPdaToken || pdaLogin.overseasAccessToken -assert(platformToken && merchantToken && pdaToken, 'Missing overseas token') -const candidates = await api(overseas, merchantToken, 'GET', - `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`) -const source = candidates.find((item) => String(item.inboundOrderNo || '').startsWith('DS-') - && Number(item.availablePackages) >= 5 - && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5)) -assert(source, 'No overseas inventory source with at least five available cartons') -const sourcePosition = source.positions.find((position) => Number(position.availablePackages) >= 5) +export async function runRelabelProducer(context, config, report) { + const { customerId, warehouseId, merchantToken, pdaToken, chargeAmount } = config + const platformToken = context.token, marker = report.marker + const key = value => `${marker}-${value}`.slice(0, 200) + const api = (token, method, path, body) => retainedApi(context, method, path, body, token) + const candidates = await api(merchantToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`) + const { source, sourcePosition } = selectExplicitRelabelSource(candidates, config) + report.source = source; report.sourcePosition = sourcePosition + await context.checkpoint() + async function resolvePallet(palletCode) { + return api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) + } -async function resolvePallet(palletCode) { - return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) -} - -async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) { - const suffix = `${name}-${Date.now()}` - const items = Array.from({ length: cartonCount }, (_, index) => ({ - sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), - sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode, - productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, - shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), - labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, - fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null, - destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, - operationType, skuItems: [{ skuCode: source.skuCode, - newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], - })) - const created = await api(overseas, platformToken, 'POST', - `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { - customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, - labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), - contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}), - remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), - deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK', - printShippingLabel: false, items, - }) - const relabelNo = created.application.relabelNo - if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), - (detail) => detail.application.walletStatus === 'FROZEN') - let detail = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) - for (const [index, item] of detail.items.entries()) { - if (operationType === 'CARTON_AND_SKU') { - detail = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { - cartonMark: item.cartonMark, expectedVersion: Number(detail.version), - idempotencyKey: key(`${name}-CARTON-${index}`), - }) - const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId)) - assert(Number(stageItem?.cartonProcessedPackages) === 1 - && Number(stageItem?.skuProcessedPackages) === 0, - `${relabelNo} carton stage incorrectly locked or completed SKU stage`) + async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) { + const suffix = `${name}-${marker}` + const items = Array.from({ length: cartonCount }, (_, index) => ({ + sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), + sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode, + productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, + shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), + labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, + fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null, + destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, + operationType, skuItems: [{ skuCode: source.skuCode, + newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], + })) + const scenario = { name, operationType, expectedCartons: cartonCount, items: [], targets: [], requestItems: items, + sourceCartonMarks: items.map(item => item.cartonMark), relabelVerified: false } + report.scenarios.push(scenario) + const created = await api(platformToken, 'POST', + `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { + customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, + labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), + contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}), + remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), + deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK', + printShippingLabel: false, items, + }) + const relabelNo = created.application.relabelNo + scenario.relabelNo = relabelNo; scenario.createResult = created + assert(created.application.overseasWarehouseId === warehouseId, 'Created relabel belongs to a different warehouse') + await context.checkpoint() + if (chargeAmount > 0) await poll(() => api(platformToken, 'GET', + `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), + (detail) => detail.application.walletStatus === 'FROZEN') + let detail = await api(pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) + for (const [index, item] of detail.items.entries()) { + if (operationType === 'CARTON_AND_SKU') { + const cartonBody = { cartonMark: item.cartonMark, expectedVersion: exactVersion(detail.version), idempotencyKey: key(`${name}-CARTON-${index}`) } + detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, cartonBody) + const cartonReplay = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, cartonBody) + assert(String(cartonReplay.version) === String(detail.version) && same(cartonReplay.items, detail.items), 'Original carton scan replay changed quantities/version') + const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId)) + assert(Number(stageItem?.cartonProcessedPackages) === 1 + && Number(stageItem?.skuProcessedPackages) === 0, + `${relabelNo} carton stage incorrectly locked or completed SKU stage`) + } + const scanBody = { + itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null, + skuVerifications: (item.skuItems || []).map((sku) => ({ + skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), + })), + expectedVersion: exactVersion(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), + } + detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, scanBody) + const scanReplay = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, scanBody) + assert(String(scanReplay.version) === String(detail.version) && same(scanReplay.items, detail.items), 'Original SKU verification replay changed quantities/version') } - detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, { - itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null, - skuVerifications: (item.skuItems || []).map((sku) => ({ - skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), - })), - expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), + const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + Object.assign(scenario, { items: detail.items, sourceBefore, sourceCartonMarks: items.map(item => item.cartonMark), requestItems: items }) + if (prepare) await prepare(scenario) + // Valid synthetic PNG for API validation; no physical warehouse photograph is claimed. + const photoResponse = await context.requestMultipart('POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, { + name: `${marker}-${name}.png`, type: 'image/png', + content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'), + }, pdaToken) + const photo = context.apiSuccess('overseas-relabel-producer', 'Upload synthetic relabel PNG', photoResponse) + assert(photoResponse.http === 200 && String(photoResponse.body?.code) === '200' && photo?.fileId, 'Synthetic photo upload failed') + scenario.photo = { fileId: id(photo.fileId), evidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK' } + detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { + photoFileIds: [id(photo.fileId)], expectedVersion: exactVersion(detail.version), + idempotencyKey: key(`${name}-COMPLETE`), remark: marker, + }) + assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) + const confirmed = await api(merchantToken, 'POST', + `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { + idempotencyKey: key(`${name}-CONFIRM`), remark: marker, + }) + const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' + assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`) + assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode), + `${relabelNo} did not preserve the destination warehouse`) + Object.assign(scenario, { items: confirmed.items, confirmedResult: confirmed, relabelVerified: true, + relabelCartonMarks: operationType === 'CARTON_AND_SKU' ? confirmed.items.map(item => item.fbaCartonMark) : [] }) + scenario.sourceAfterRelabel = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + await context.checkpoint() + return scenario + } + + async function printPallet(relabelNo, suffix) { + return api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { + relabelNo, idempotencyKey: key(`PRINT-${suffix}`), }) } - const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) - const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore } - if (prepare) await prepare(scenario) - const form = new FormData() - // Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work. - form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`) - const photo = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form) - detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { - photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version), - idempotencyKey: key(`${name}-COMPLETE`), remark: marker, - }) - assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) - const confirmed = await api(overseas, merchantToken, 'POST', - `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { - idempotencyKey: key(`${name}-CONFIRM`), remark: marker, + async function moveCarton(scenario, item, target, index) { + const sourcePallet = await resolvePallet(sourcePosition.palletNo) + const targetPallet = await resolvePallet(target.palletCode) + const result = await api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { + idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo, + source: { palletCode: sourcePallet.palletCode, expectedVersion: exactVersion(sourcePallet.palletVersion) }, + target: { palletCode: targetPallet.palletCode, expectedVersion: exactVersion(targetPallet.palletVersion) }, + cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`, }) - const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' - assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`) - assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode), - `${relabelNo} did not preserve the destination warehouse`) - return { ...scenario, items: confirmed.items } -} + assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type') + return result + } -async function printPallet(relabelNo, suffix) { - return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { - relabelNo, idempotencyKey: key(`PRINT-${suffix}`), + const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL') + skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets') + skuOnly.targetPallets = [] + skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode + + + let sharedTarget + const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => { + sharedTarget = await printPallet(scenario.relabelNo, 'SHARED') + const printReplay = await printPallet(scenario.relabelNo, 'SHARED') + assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] }) -} -async function moveCarton(scenario, item, target, index) { - const sourcePallet = await resolvePallet(sourcePosition.palletNo) - const targetPallet = await resolvePallet(target.palletCode) - const result = await api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { - idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo, - source: { palletCode: sourcePallet.palletCode, expectedVersion: Number(sourcePallet.palletVersion) }, - target: { palletCode: targetPallet.palletCode, expectedVersion: Number(targetPallet.palletVersion) }, - cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`, + + const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) + const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => { + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] }) - assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type') - return result -} + const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) + assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') + merged.targets = [sharedTarget] + report.sharedMerge = { palletId: id(sharedTarget.palletId), palletCode: sharedTarget.palletCode, beforePackages: sharedBefore, afterPackages: sharedAfter, + sourceScenarios: [{ name: fresh.name, cartons: fresh.expectedCartons }, { name: merged.name, cartons: merged.expectedCartons }] } -const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL') -skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) -assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets') -skuOnly.targetPallets = [] -skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode -delete skuOnly.items - -let sharedTarget -const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => { - sharedTarget = await printPallet(scenario.relabelNo, 'SHARED') - const printReplay = await printPallet(scenario.relabelNo, 'SHARED') - assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') - await moveCarton(scenario, scenario.items[0], sharedTarget, 0) - scenario.targets = [sharedTarget] -}) - -const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) -const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => { - await moveCarton(scenario, scenario.items[0], sharedTarget, 0) - scenario.targets = [sharedTarget] -}) -const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) -assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') -merged.targets = [sharedTarget] - -const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => { - const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A') - const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B') - await moveCarton(scenario, scenario.items[0], splitA, 0) - await moveCarton(scenario, scenario.items[1], splitB, 1) - scenario.targets = [splitA, splitB] -}) - -for (const scenario of [fresh, merged, split]) { - const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) - scenario.sourceAfter = sourceAfter - scenario.targetPallets = scenario.targets.map((target) => ({ palletId: id(target.palletId), palletCode: target.palletCode })) - const candidateRows = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(scenario.relabelNo)}&sourceStage=RECEIVED`) - const targetIds = new Set(scenario.targets.map((target) => id(target.palletId))) - const rows = candidateRows.filter((row) => row.sourceStage === 'RELABEL' && targetIds.has(id(row.warehousePalletId))) - const marks = rows.flatMap((row) => String(row.relabelCartonMarks || '').split(',').filter(Boolean)) - assert(rows.length === targetIds.size, `${scenario.relabelNo} missing smart-dispatch pallet candidate`) - assert(rows.every((row) => !String(row.cartonMarks || '').trim()), `${scenario.relabelNo} exposed normal carton marks`) - assert(scenario.items.every((item) => marks.includes(item.fbaCartonMark)), `${scenario.relabelNo} missing relabel carton mark`) - const dispatch = await api(overseas, platformToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', { - planPalletIds: rows.map((row) => id(row.warehousePalletId)), route: `${marker}-${scenario.name}`, - relabelNo: scenario.relabelNo, + const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => { + const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A') + const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B') + await moveCarton(scenario, scenario.items[0], splitA, 0) + await moveCarton(scenario, scenario.items[1], splitB, 1) + scenario.targets = [splitA, splitB] }) - scenario.dispatchNo = dispatch.dispatchNo - scenario.dispatchStatus = dispatch.status - scenario.relabelCartonMarks = marks - delete scenario.items - delete scenario.targets + + for (const scenario of [fresh, merged, split]) scenario.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode + const ledger = await api(platformToken, 'GET', `/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`) + const ledgerRows = ledger.records.filter(row => String(row.remark || '').includes(marker)) + assert(ledgerRows.some(row => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') + assert(ledgerRows.some(row => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') + assert(ledgerRows.every(row => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') + report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map(row => Number(row.deltaPackages)), records: ledgerRows } + report.relabelScenariosVerified = report.scenarios.length === 4 && report.scenarios.every(scenario => scenario.relabelVerified) + await publishNativeRelabelDispatches(context, report, config) + report.passed = report.relabelScenariosVerified && !report.blockers.length + report.finalStatus = report.passed ? 'RELABEL_AND_NATIVE_DISPATCH_VERIFIED' : 'BLOCKED_NATIVE_DISPATCH_OR_PHYSICAL_LOADING_EVIDENCE' + await context.checkpoint(); return report } -const ledger = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`) -const ledgerRows = ledger.records.filter((row) => String(row.remark || '').includes(marker)) -assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') -assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') -assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') -report.scenarios = [skuOnly, fresh, merged, split] -report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) } -report.passed = true -report.finishedAt = new Date().toISOString() -await writeFile(output, JSON.stringify(report, null, 2), 'utf8') -console.log(JSON.stringify(report, null, 2)) +export async function mainRelabelProducer(env = process.env) { + const options = parseArguments([]); options.modules = [] + const output = resolve(env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json') + options.output = output; validateExecutionOptions(options) + requireFact(options.environment === 'test', 'Relabel producer only supports explicit TEST') + const customerId = id(env.OVERSEAS_TEST_CUSTOMER_ID), warehouseId = id(env.OVERSEAS_TEST_WAREHOUSE_ID) + const sourceBalanceId = id(env.OVERSEAS_RELABEL_SOURCE_BALANCE_ID), sourcePositionId = id(env.OVERSEAS_RELABEL_SOURCE_POSITION_ID) + requireFact(/^[1-9]\d*$/.test(customerId) && /^[1-9]\d*$/.test(warehouseId) && /^[1-9]\d*$/.test(sourceBalanceId) && /^[1-9]\d*$/.test(sourcePositionId), + 'Explicit customer, warehouse, original source balance and position IDs are required before HTTP') + requireFact(!existsSync(output), 'Existing producer checkpoint must be reconciled with its original commands; do not overwrite it or regenerate a marker') + const chargeAmount = Number(env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01') + requireFact(Number.isFinite(chargeAmount) && chargeAmount >= 0, 'Invalid charge amount') + const context = new AcceptanceRun(options), marker = env.OVERSEAS_RELABEL_MARKER || context.marker + requireFact(/^[A-Za-z0-9_-]+$/.test(marker), 'Invalid explicit producer marker') + const report = { marker, environment: 'TEST', startedAt: new Date().toISOString(), scope: 'RELABEL_PRODUCER_AND_NATIVE_DISPATCH_ONLY', + chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], blockers: [], passed: false, requests: [] } + const raw = async (method, path, body, token) => { + const response = await context.request(method, path, body, token) + requireFact(response.http === 200 && String(response.body?.code) === '200', 'Unified authentication failed') + return response.body.data + } + try { + // Session tickets and tokens remain in memory and never enter the business journal. + const platform = await raw('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + context.token = platform.accessToken + requireFact(context.token && options.pdaUsername && options.pdaPassword, 'Unified platform and explicit PDA credentials required') + const pda = await raw('POST', '/api/auth/login', { loginId: options.pdaUsername, password: options.pdaPassword }, '') + let merchant + if (options.merchantUsername && options.merchantPassword) merchant = await raw('POST', '/api/auth/login', { loginId: options.merchantUsername, password: options.merchantPassword }, '') + else { + const ticket = await raw('POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} TEST relabel producer` }, context.token) + merchant = await raw('POST', '/api/auth/impersonations/exchange', { code: ticket.code }, '') + } + requireFact(pda.accessToken && merchant.accessToken, 'Unified PDA/customer accessToken required') + const profile = await raw('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchant.accessToken) + requireFact(id(profile.id) === customerId, 'Merchant session does not own the explicit customer') + installRelabelJournal(context, report, () => saveRetainedReport(output, report)); context.marker = marker + const loadingEvidenceByScenario = env.OVERSEAS_RELABEL_LOADING_EVIDENCE ? JSON.parse(readFileSync(resolve(env.OVERSEAS_RELABEL_LOADING_EVIDENCE), 'utf8')) : undefined + await runRelabelProducer(context, { customerId, warehouseId, sourceBalanceId, sourcePositionId, inboundCode: env.OVERSEAS_RELABEL_SOURCE_INBOUND_CODE, + merchantToken: merchant.accessToken, pdaToken: pda.accessToken, chargeAmount, loadingEvidenceByScenario }, report) + } catch (error) { + report.passed = false; report.finalStatus = report.requests.some(row => ['SENDING', 'UNKNOWN'].includes(row.status)) ? 'UNKNOWN_WRITE_STOPPED' : 'BLOCKED' + report.failedStage = report.scenarios.at(-1)?.name || 'CUSTOMER_OR_EXPLICIT_SOURCE'; process.exitCode = 1 + } finally { + report.checks = context.steps; report.finishedAt = new Date().toISOString() + if (!report.passed || context.steps.some(step => step.passed === false)) process.exitCode = 1 + saveRetainedReport(output, report) + console.log(JSON.stringify({ marker, finalStatus: report.finalStatus, passed: report.passed, + relabelScenariosVerified: report.relabelScenariosVerified, scenarios: report.scenarios.map(scenario => ({ name: scenario.name, + relabelNo: scenario.relabelNo, dispatchNo: scenario.dispatchNo, dispatchStatus: scenario.dispatchStatus })), report: output })) + } + return report +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainRelabelProducer() diff --git a/scripts/overseas-relabel-real-contract.test.mjs b/scripts/overseas-relabel-real-contract.test.mjs new file mode 100644 index 000000000..f28c8d048 --- /dev/null +++ b/scripts/overseas-relabel-real-contract.test.mjs @@ -0,0 +1,289 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installRelabelJournal, selectExplicitRelabelSource, nativeRelabelCreateBody, nativeRelabelGroups, relabelGroupMeasurements, runRelabelProducer } from './overseas-relabel-real-acceptance.mjs' +import { retainedRelabelSource } from './overseas-relabel-fulfillment-acceptance.mjs' +const ok = data => ({ http: 200, body: { code: '200', data }, requestId: 'REQ', traceId: 'TRACE' }) +const config = () => ({ customerId: '100', warehouseId: '80', sourceBalanceId: '1', sourcePositionId: '2', merchantToken: 'MERCHANT-MEMORY', pdaToken: 'PDA-MEMORY', chargeAmount: 0 }) +const sourceCandidate = () => ({ inventoryBalanceId: '1', inboundOrderId: '3', inboundOrderNo: 'DS-TEST', inboundCode: 'IN-TEST', overseasWarehouseId: '80', + availablePackages: '5', skuCode: 'SKU', destinationFbaWarehouseCode: 'ABE4', positions: [{ positionId: '2', palletNo: 'SOURCE', availablePackages: '5' }] }) + +test('explicit original balance/position selection never borrows history or a different customer warehouse', () => { + const candidate = sourceCandidate() + assert.equal(selectExplicitRelabelSource([candidate], config()).source, candidate) + for (const mutate of [value => { value.sourceBalanceId = '' }, value => { value.sourcePositionId = '99' }, + value => { value.warehouseId = '81' }, value => { value.inboundCode = 'OTHER' }]) { + const value = config(); mutate(value); assert.throws(() => selectExplicitRelabelSource([candidate], value)) + } + assert.throws(() => selectExplicitRelabelSource([candidate, candidate], config()), /ambiguous/) + const unavailable = sourceCandidate(); unavailable.positions[0].availablePackages = '4' + assert.throws(() => selectExplicitRelabelSource([unavailable], config()), /five real/) +}) + +test('empty successful relabel response is UNKNOWN, keeps the original version/key, and stops replacement writes', async () => { + const report = { marker: 'RUN-1', requests: [] }, snapshots = [], calls = [] + const context = installRelabelJournal({ context: {}, createdResources: {}, + async request(method, path, body) { calls.push({ method, path, body }); assert.equal(snapshots.at(-1).requests.at(-1).status, 'SENDING'); return ok(null) }, + async requestMultipart() { throw Error('Unexpected upload') }, + }, report, async () => snapshots.push(structuredClone(report))) + const original = { expectedVersion: '0', idempotencyKey: 'ORIGINAL', oldLabel: 'OLD' } + await assert.rejects(context.request('POST', '/api/pda/v1/last-leg/relabel-tasks/RL-1/units', original, 'PDA-MEMORY'), /business response missing/) + assert.equal(report.requests[0].status, 'UNKNOWN'); assert.deepEqual(report.requests[0].body, original) + assert.equal(report.requests[0].requestId, 'REQ') + await assert.rejects(context.request('POST', '/api/pda/v1/last-leg/relabel-tasks/RL-1/units', { ...original, idempotencyKey: 'NEW' }), /stop with original/) + assert.equal(calls.length, 1) +}) +for (const data of [null, {}, { dispatchNo: 'DS-UNKNOWN', action: 'CREATE', recordVersion: '0', result: null }]) test(`native CREATE without its original business result stops with its original key: ${JSON.stringify(data)}`, async () => { + const report = { marker: 'RUN-1', requests: [] }, calls = [] + const context = installRelabelJournal({ context: {}, createdResources: {}, request: async (...args) => { calls.push(args); return ok(data) }, requestMultipart: async () => { throw Error('Unexpected upload') } }, report, async () => {}) + const body = { idempotencyKey: 'ORIGINAL-CREATE', cargo: [{ sourceType: 'RELABEL', relabelNo: 'RL-1', warehousePalletId: '9007199254740993', sourceVersion: 'a'.repeat(64) }] } + await assert.rejects(context.request('POST', '/api/platform/v1/last-leg/dispatches', body), /Unknown/) + assert.equal(report.requests[0].status, 'UNKNOWN'); assert.deepEqual(report.requests[0].body, body) + await assert.rejects(context.request('POST', '/api/platform/v1/last-leg/dispatches', { ...body, idempotencyKey: 'NEW' }), /Unknown/) + assert.equal(calls.length, 1) +}) + +function producerFixture(corruptCartonStage = false, mutateCandidates = value => value, mutateDetail = value => value) { + const report = { environment: 'TEST', marker: 'RUN-1', requests: [], scenarios: [], blockers: [], passed: false }, calls = [], snapshots = [] + const applications = new Map(), replay = new Map(), pallets = new Map([['SOURCE', { palletId: '4', palletCode: 'SOURCE', totalPackages: 5, palletVersion: 0 }]]) + let appSequence = 0, palletSequence = 10, photoSequence = 20 + const dispatches = new Map() + const clone = value => structuredClone(value) + const detail = app => ({ relabelNo: app.no, version: app.version, items: clone(app.items), applicationStatus: app.status }) + const context = { token: 'PLATFORM-MEMORY', context: {}, createdResources: {}, steps: [], + apiSuccess(_m, _l, response) { return response.body?.data }, addCoverageGap() {}, + addStep(_m, _l, _r, passed) { this.steps.push({ passed }) }, + async requestMultipart(method, path, file) { + calls.push({ method, path, file: { name: file.name, type: file.type, size: file.content.length } }) + assert.equal(file.content.subarray(0, 8).toString('hex'), '89504e470d0a1a0a') + return ok({ fileId: String(photoSequence++) }) + }, + async request(method, path, body, token) { + calls.push({ method, path, body: clone(body), token }) + if (path.includes('/inventory-candidates')) { assert.equal(token, 'MERCHANT-MEMORY'); return ok([sourceCandidate()]) } + if (path.startsWith('/api/platform/v1/last-leg/relabel-applications?') && method === 'POST') { + const no = `RL-${++appSequence}`, items = body.items.map((item, index) => ({ ...clone(item), id: `90071992547410${appSequence}${index}`, + itemId: `90071992547410${appSequence}${index}`, cartonProcessedPackages: 0, skuProcessedPackages: 0 })) + const app = { no, items, version: 0, body: clone(body), status: 'PROCESSING' }; applications.set(no, app) + return ok({ application: { relabelNo: no, overseasWarehouseId: '80' }, items: clone(items) }) + } + if (path.includes('/relabel-tasks/')) { + assert.equal(token, 'PDA-MEMORY') + const no = path.split('/relabel-tasks/')[1].split('/')[0], app = applications.get(no) + if (replay.has(body.idempotencyKey)) return ok(clone(replay.get(body.idempotencyKey))) + if (path.endsWith('/claim')) return ok(detail(app)) + if (path.endsWith('/cartons/scan')) { + assert.equal(body.expectedVersion, app.version) + const item = app.items.find(item => item.cartonMark === body.cartonMark); item.cartonProcessedPackages = 1 + if (corruptCartonStage) item.skuProcessedPackages = 1 + app.version++ + } else if (path.endsWith('/units')) { + assert.equal(body.expectedVersion, app.version) + const item = app.items.find(item => item.itemId === body.itemId) + assert.equal(body.skuVerifications[0].quantity, 1); item.skuProcessedPackages = 1; app.version++ + } else if (path.endsWith('/complete')) { + assert.equal(body.expectedVersion, app.version); assert.equal(body.photoFileIds.length, 1) + app.status = 'CUSTOMER_CONFIRM_PENDING'; app.version++ + } else throw Error(`Unexpected relabel task ${path}`) + const result = detail(app); replay.set(body.idempotencyKey, clone(result)); return ok(result) + } + if (path.includes('/confirm-completion')) { + assert.equal(token, 'MERCHANT-MEMORY') + const no = path.split('/relabel-applications/')[1].split('/')[0], app = applications.get(no) + return ok({ application: { relabelNo: no, overseasWarehouseId: '80', status: app.body.items[0].operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' }, items: clone(app.items) }) + } + if (path.endsWith('/pallets/resolve')) return ok(clone(pallets.get(body.code))) + if (path.endsWith('/relabel-pallet-label')) { + if (replay.has(body.idempotencyKey)) return ok(clone(replay.get(body.idempotencyKey))) + const palletId = String(palletSequence++), palletCode = `P-${palletId}` + const value = { palletId, palletCode, totalPackages: 0, palletVersion: 0 }; pallets.set(palletCode, value) + replay.set(body.idempotencyKey, value); return ok(clone(value)) + } + if (path.endsWith('/relabel-disassemble')) { + pallets.get(body.source.palletCode).totalPackages--; pallets.get(body.source.palletCode).palletVersion++ + pallets.get(body.target.palletCode).totalPackages++; pallets.get(body.target.palletCode).palletVersion++ + return ok({ operationType: 'RELABEL_PALLET_DISASSEMBLE' }) + } + if (path.includes('/inventory/ledger?')) return ok({ records: [{ remark: '换标拆托 RUN-1', deltaPackages: -1 }, { remark: '换标拆托 RUN-1', deltaPackages: 1 }] }) + if (path.endsWith('/relabel-pallet-measurements')) return ok([]) + if (path.includes('/dispatches/candidates?')) { + const query = new URL(path, 'https://offline.invalid').searchParams + assert.equal(query.get('warehouseId'), '80'); assert.equal(query.get('sourceStage'), 'RELABEL') + assert.equal(query.has('orderNo'), false) + const rows = report.scenarios.filter(scenario => scenario.operationType === 'CARTON_AND_SKU').flatMap(scenario => scenario.targets.map(target => ({ + selection: { sourceType: 'RELABEL', relabelNo: scenario.relabelNo, planPalletId: null, returnReceiptId: null, warehousePalletId: target.palletId, sourceVersion: 'a'.repeat(64) }, + sourceKey: `RELABEL:${scenario.relabelNo}:${target.palletId}`, sourceVersion: 'a'.repeat(64), customerId: '100', warehouseId: '80', + palletNo: target.palletCode, availablePackages: '1', destination: { type: 'FBA', warehouseCode: 'ABE4', key: 'FBA:ABE4' }, blockReasons: [], appointmentBatches: [] }))) + for (const subset of [rows.filter(row => row.selection.relabelNo !== 'RL-4'), rows.filter(row => row.selection.relabelNo === 'RL-4')]) attachGroup(subset) + return ok({ complete: true, candidates: mutateCandidates(rows) }) + } + if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { + assert.equal(calls.filter(call => call.path.includes('/dispatches/candidates?')).length, 1) + assert.equal(body.cargo.length, 2) + const old = dispatches.get(body.idempotencyKey) + if (old) { assert.deepEqual(body, old.body); return ok({ ...clone(old.result), replayed: true }) } + const dispatchNo = `DS-NATIVE-${dispatches.size + 1}` + const result = { dispatchNo, action: 'CREATE', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0', result: { dispatchNo, changedItemIds: ['9007199254740993'] } } + dispatches.set(body.idempotencyKey, { body: clone(body), result }); return ok(result) + } + if (path.includes('/dispatches/DS-NATIVE-') && method === 'GET') { + const dispatch = [...dispatches.values()].find(value => path.endsWith(value.result.dispatchNo)) + return ok(mutateDetail({ dispatchNo: dispatch.result.dispatchNo, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0', + counts: { packageCount: '2', palletCount: String(new Set(dispatch.body.cargo.map(row => row.warehousePalletId)).size) }, + orderDetails: dispatch.body.cargo.map(row => ({ customerId: '100', inboundCode: 'IN-TEST', sourceBusinessType: 'RELABEL', sourceBusinessNo: row.relabelNo, sourceKey: `RELABEL:${row.relabelNo}:${row.warehousePalletId}` })) })) + } + throw Error(`Unexpected offline producer ${method} ${path}`) + }, + } + installRelabelJournal(context, report, async () => { snapshots.push(clone(report)) }) + return { context, report, calls, snapshots } +} + +function attachGroup(rows) { + const sources = rows.map(row => ({ selection: structuredClone(row.selection), sourceKey: row.sourceKey, customerId: row.customerId, customerName: 'TEST', sourceBusinessType: 'RELABEL', sourceBusinessNo: row.selection.relabelNo, packages: row.availablePackages })) + const pallets = [...new Set(rows.map(row => row.selection.warehousePalletId))].map(warehousePalletId => ({ warehousePalletId, palletNo: rows.find(row => row.selection.warehousePalletId === warehousePalletId).palletNo, + sourceKeys: sources.filter(source => source.selection.warehousePalletId === warehousePalletId).map(source => source.sourceKey), packageCount: String(sources.filter(source => source.selection.warehousePalletId === warehousePalletId).reduce((sum, source) => sum + Number(source.packages), 0)) })) + const group = { groupKey: `GROUP-${sources[0].sourceBusinessNo}`, groupVersion: 'b'.repeat(64), sources, pallets, complete: true, blockReasons: [] } + for (const row of rows) row.selectionGroup = structuredClone(group) + return group +} + +test('four relabel scenes retain scans/photos/counts; shared NEW/MERGE create one group once and split retains its application closure', async () => { + const f = producerFixture() + await runRelabelProducer(f.context, config(), f.report) + assert.deepEqual(f.report.scenarios.map(scenario => scenario.name), ['SKU-ONLY', 'NEW-PALLET', 'MERGE-EXISTING', 'SPLIT-TWO-TARGETS']) + assert.equal(f.report.relabelScenariosVerified, true) + assert.equal(f.report.scenarios[0].sourceBefore, f.report.scenarios[0].sourceAfter) + assert.deepEqual(f.report.sharedMerge.sourceScenarios.map(row => row.cartons), [1, 1]) + assert.equal(f.report.sharedMerge.beforePackages, 1); assert.equal(f.report.sharedMerge.afterPackages, 2) + const fresh = f.report.scenarios[1], merged = f.report.scenarios[2] + assert.equal(fresh.nativeDispatch, merged.nativeDispatch); assert.equal(fresh.dispatchNo, 'DS-NATIVE-1'); assert.equal(merged.dispatchNo, fresh.dispatchNo) + assert.equal(fresh.nativeDispatch.createBody.cargo.length, 2); assert.equal(new Set(fresh.nativeDispatch.createBody.cargo.map(row => row.warehousePalletId)).size, 1) + assert.deepEqual([fresh, merged].map(scenario => [scenario.sourceContributions[0].sourceBusinessNo, scenario.sourceContributions[0].packages]), [['RL-2', '1'], ['RL-3', '1']]) + assert.deepEqual(fresh.nativeDispatch.detail.orderDetails.map(row => row.sourceBusinessNo), ['RL-2', 'RL-3']) + assert.equal(f.calls.filter(call => call.path === '/api/platform/v1/last-leg/dispatches').length, 4) + const split = f.report.scenarios[3] + assert.equal(split.nativeDispatch.createBody.cargo.length, 2); assert.equal(split.nativeDispatch.createResult.recordVersion, '0') + assert.equal(split.nativeDispatch.createReplay.replayed, true) + assert.equal(split.items.length, 2); assert.equal(split.targets.length, 2) + assert.equal(split.sourceCartonMarks.length, 2); assert.equal(split.relabelCartonMarks.length, 2) + assert.equal(split.nativeDispatch.loadingMeasurements, undefined) + assert.equal(split.nativeDispatchBlocker.code, 'ACTUAL_LOADING_MEASUREMENT_REQUIRED') + assert.equal(f.report.passed, false) + assert.equal(retainedRelabelSource(f.report, '100', 'DS-NATIVE-2').scope.expectedPackages, 2) + assert.equal(f.calls.filter(call => call.path.endsWith('/photos/files')).length, 4) + assert.equal(f.calls.some(call => /pre-arrange/.test(call.path)), false) + const scans = f.calls.filter(call => call.path.endsWith('/units')) + assert.equal(scans.length, 10) + for (let index = 0; index < scans.length; index += 2) assert.deepEqual(scans[index].body, scans[index + 1].body) +}) + +test('carton stage may not complete the SKU stage and a failure keeps original scene items/source request proof', async () => { + const f = producerFixture(true) + await assert.rejects(runRelabelProducer(f.context, config(), f.report), /carton stage incorrectly/) + assert.equal(f.report.scenarios[0].relabelVerified, true) + assert.equal(f.report.scenarios[1].relabelVerified, false) + assert.equal(f.report.scenarios[1].requestItems.length, 1) + assert.equal(f.report.scenarios[1].sourceCartonMarks.length, 1) + assert.equal(f.calls.some(call => call.path === '/api/platform/v1/last-leg/dispatches'), false) +}) + +test('native producer selectors cannot replace customer, warehouse, destination, original source hash or physical quantity', () => { + const scenario = { name: 'SPLIT-TWO-TARGETS', relabelVerified: true, relabelNo: 'RL-1', targets: [{ palletId: '9007199254740993' }], items: [{ notifiedPackageCount: 1 }], destinationFbaWarehouseCode: 'ABE4' } + const candidate = { selection: { sourceType: 'RELABEL', relabelNo: 'RL-1', warehousePalletId: '9007199254740993', sourceVersion: 'a'.repeat(64) }, + sourceKey: 'RELABEL:RL-1:9007199254740993', sourceVersion: 'a'.repeat(64), customerId: '100', warehouseId: '80', availablePackages: '1', palletNo: 'P-ORIGINAL', + destination: { type: 'FBA', warehouseCode: 'ABE4' }, blockReasons: [], appointmentBatches: [] } + attachGroup([candidate]) + assert.equal(nativeRelabelCreateBody('RUN-1', [scenario], [candidate], '100', '80').cargo[0].warehousePalletId, '9007199254740993') + for (const mutate of [value => { value.customerId = '200' }, value => { value.warehouseId = '81' }, + value => { value.destination.warehouseCode = 'OTHER' }, value => { value.availablePackages = '2' }, + value => { value.selection.sourceVersion = 'stale' }, value => { value.cartonMarks = 'OLD' }]) { + const value = structuredClone(candidate); mutate(value); assert.throws(() => nativeRelabelCreateBody('RUN-1', [scenario], [value], '100', '80')) + } +}) + +for (const fault of ['missing group', 'incomplete group', 'group blocker', 'missing version', 'version drift', 'member selector version', 'unknown business', 'missing member', + 'duplicate member', 'source packages', 'physical packages', 'missing physical source', 'duplicate physical', 'split application closure']) test(`authority group preflight blocks every CREATE: ${fault}`, async () => { + const f = producerFixture(false, rows => { + if (fault === 'missing group') delete rows[0].selectionGroup + if (fault === 'incomplete group') rows[0].selectionGroup.complete = false + if (fault === 'group blocker') rows[0].selectionGroup.blockReasons = ['真实组尚未核平'] + if (fault === 'missing version') rows[0].selectionGroup.groupVersion = '' + if (fault === 'version drift') rows[0].selectionGroup.groupVersion = 'CHANGED' + if (fault === 'member selector version') rows[0].selectionGroup.sources[0].selection.sourceVersion = 'c'.repeat(64) + if (fault === 'unknown business') rows[0].selectionGroup.sources[0].sourceBusinessNo = 'RL-HISTORY' + if (fault === 'missing member') rows.splice(1, 1) + if (fault === 'duplicate member') rows.push(structuredClone(rows[0])) + if (fault === 'source packages') rows[0].selectionGroup.sources[0].packages = '2' + if (fault === 'physical packages') rows[0].selectionGroup.pallets[0].packageCount = '3' + if (fault === 'missing physical source') rows[0].selectionGroup.pallets[0].sourceKeys.pop() + if (fault === 'duplicate physical') rows[0].selectionGroup.pallets.push(structuredClone(rows[0].selectionGroup.pallets[0])) + if (fault === 'split application closure') rows.at(-1).selectionGroup.groupKey = 'OTHER-PHYSICAL-GROUP' + if (['incomplete group', 'group blocker', 'missing version', 'member selector version', 'unknown business', 'source packages', 'physical packages', 'missing physical source', 'duplicate physical'].includes(fault)) + for (const row of rows.slice(1).filter(row => row.selectionGroup.groupKey === rows[0].selectionGroup.groupKey)) row.selectionGroup = structuredClone(rows[0].selectionGroup) + return rows + }) + await assert.rejects(runRelabelProducer(f.context, config(), f.report)) + assert.equal(f.report.relabelScenariosVerified, true) + assert.equal(f.calls.some(call => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/dispatches'), false) + assert.ok(f.report.nativeSelectionFailure.message); assert.equal(f.report.passed, false) +}) + +test('native source business number is required in actual Detail and cannot fall back to inboundCode', async () => { + const f = producerFixture(false, rows => rows, detail => { delete detail.orderDetails[0].sourceBusinessNo; return detail }) + await runRelabelProducer(f.context, config(), f.report) + assert.equal(f.report.passed, false) + assert.equal(f.report.scenarios[1].nativeDispatchBlocker.code, 'NATIVE_DISPATCH_NOT_VERIFIED') + assert.equal(f.report.scenarios[1].nativeDispatch.createResult.recordVersion, '0') + assert.equal(f.report.scenarios[1].nativeDispatch.createReplay.replayed, true) +}) + +function loadingReceipt(dispatchNo, palletIds) { + return { environment: 'TEST', marker: 'RUN-1', dispatchNo, pallets: palletIds.map(warehousePalletId => ({ warehousePalletId, palletSpaces: 2, weightKg: '15.5', volumeCbm: '0.75', deliveryIndex: 1, + evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map((field, index) => ({ field, value: [2, '15.5', '0.75'][index], actual: true, sourceType: 'OFFLINE_TEST_RECEIPT', sourceBusinessNo: 'OFFLINE-ONLY', observedAt: '2026-10-19T00:00:00Z' })) })) } +} +test('consistent shared scenario loading receipts publish one physical envelope and one dispatch command pair', async () => { + const f = producerFixture(), c = config() + c.loadingEvidenceByScenario = { 'NEW-PALLET': loadingReceipt('DS-NATIVE-1', ['10']), 'MERGE-EXISTING': loadingReceipt('DS-NATIVE-1', ['10']), 'SPLIT-TWO-TARGETS': loadingReceipt('DS-NATIVE-2', ['11', '12']) } + await runRelabelProducer(f.context, c, f.report) + assert.equal(f.report.passed, true) // Offline script contract only; no environment claim. + const fresh = f.report.scenarios[1], merged = f.report.scenarios[2] + assert.equal(fresh.nativeDispatch, merged.nativeDispatch) + assert.equal(fresh.nativeDispatch.loadingMeasurements.pallets.length, 1) + assert.equal(fresh.nativeDispatch.loadingMeasurements.pallets[0].weightKg, '15.5') + assert.equal(f.calls.filter(call => call.path === '/api/platform/v1/last-leg/dispatches').length, 4) + assert.equal(f.calls.some(call => /loading-complete|\/pick$|\/depart$/.test(call.path)), false) +}) +for (const fault of ['dispatch', 'weight', 'space', 'stop', 'pallet', 'duplicate']) test(`shared loading receipts conflict before any CREATE: ${fault}`, async () => { + const f = producerFixture(), c = config(), fresh = loadingReceipt('DS-NATIVE-1', ['10']), merged = structuredClone(fresh) + if (fault === 'dispatch') merged.dispatchNo = 'DS-OTHER' + if (fault === 'weight') { merged.pallets[0].weightKg = '16'; merged.pallets[0].evidence[1].value = '16' } + if (fault === 'space') { merged.pallets[0].palletSpaces = 3; merged.pallets[0].evidence[0].value = 3 } + if (fault === 'stop') merged.pallets[0].deliveryIndex = 2 + if (fault === 'pallet') merged.pallets[0].warehousePalletId = '9007199254740993' + if (fault === 'duplicate') merged.pallets.push(structuredClone(merged.pallets[0])) + c.loadingEvidenceByScenario = { 'NEW-PALLET': fresh, 'MERGE-EXISTING': merged } + await assert.rejects(runRelabelProducer(f.context, c, f.report)) + assert.equal(f.calls.some(call => call.path === '/api/platform/v1/last-leg/dispatches'), false) +}) +test('shared physical measurements deduplicate whole-pallet mass and reject composition or value drift', () => { + const measured = { palletId: '9007199254740993', palletCode: 'P-SHARED', packages: '2', stockVersion: 'STOCK-ORIGINAL', lengthCm: '100', widthCm: '100', heightCm: '100', grossWeightKg: '15.5', volumeCbm: '1' }, + group = { pallets: [{ warehousePalletId: '9007199254740993', palletNo: 'P-SHARED', packageCount: '2' }] }, + scenarios = [{ physicalMeasurements: [measured] }, { physicalMeasurements: [structuredClone(measured)] }] + const result = relabelGroupMeasurements(scenarios, group) + assert.equal(result.length, 1); assert.equal(result[0].grossWeightKg, '15.5') + scenarios[1].physicalMeasurements[0].stockVersion = 'NEW-STOCK' + assert.throws(() => relabelGroupMeasurements(scenarios, group), /conflict/) + scenarios[1].physicalMeasurements[0] = { ...measured, packages: '1' } + assert.throws(() => relabelGroupMeasurements(scenarios, group), /complete shared pallet/) +}) + +test('producer import is inert, current TEST Auth is unified, and evidence is retained instead of deleting scenario inputs', () => { + const source = readFileSync(new URL('./overseas-relabel-real-acceptance.mjs', import.meta.url), 'utf8') + assert.match(source, /fileURLToPath\(import.meta.url\)\) await mainRelabelProducer\(\)/) + assert.doesNotMatch(source, /overseasAccessToken|ADMIN_OVERSEAS_GATEWAY|ADMIN_OVERSEAS_ACCESS_TOKEN|delete scenario.items|delete scenario.targets|\/dispatches\/pre-arrange/) + assert.match(source, /options.environment === 'test'/) + assert.match(source, /profile.id\) === customerId/) + assert.match(source, /SYNTHETIC_PNG_NOT_PHYSICAL_WORK/) + assert.match(source, /Existing producer checkpoint must be reconciled/) +}) diff --git a/scripts/prod-release-overseas-stage.mjs b/scripts/prod-release-overseas-stage.mjs index e936369ef..27e28f075 100644 --- a/scripts/prod-release-overseas-stage.mjs +++ b/scripts/prod-release-overseas-stage.mjs @@ -1,10 +1,15 @@ +import { readFile } from 'node:fs/promises' +import { isAbsolute } from 'node:path' +import { createHash } from 'node:crypto' import { fingerprint } from './prod-release-order-stage.mjs' import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' const BASE = '/api/platform/v1/last-leg' const PDA = '/api/pda/v1/last-leg' const check = (ok, message) => { if (!ok) throw new Error(message) } -const id = value => value == null ? '' : String(value) +const id = value => { check(typeof value !== 'number' || Number.isSafeInteger(value), '业务ID精度无效'); return value == null ? '' : String(value) } +const version = value => { check(/^(0|[1-9]\d*)$/.test(String(value)) && (typeof value !== 'number' || Number.isSafeInteger(value)), '原生排车recordVersion缺失或精度无效'); return value } +const DISPATCH_PROTOCOL = 'NATIVE_DISPATCH_V1' const query = (path, values) => `${path}?${new URLSearchParams(values)}` const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') @@ -17,13 +22,14 @@ export function overseasCommands(context, checkpoint) { return response.body.data } async function command(name, method, path, body, token = context.token, headers = {}, file = null) { - const binding = fingerprint({ method, path, body: body ?? null, headers, file }) + const request = { method, path, body: body ?? null, headers, file } + const binding = fingerprint(request) const previous = state.commands?.[name] check(!previous || previous.binding === binding, `海外命令 ${name} 与检查点不符`) if (previous?.status === 'DONE') return previous.result check(!previous, `海外命令 ${name} 结果未知;停止重发并保留检查点`) const persist = async value => { const commands = { ...state.commands, [name]: value }; await save({ commands }); state.commands = commands } - await persist({ binding, status: 'INTENT' }) + await persist({ binding, request, status: 'INTENT' }) let response if (file) { const form = new FormData() @@ -31,10 +37,22 @@ export function overseasCommands(context, checkpoint) { response = await context.requestWithBody(method, path, form, token) } else response = await context.request(method, path, body, token, headers) const result = unwrap(response) - await persist({ binding, status: 'DONE', result }) + await persist({ binding, request, status: 'DONE', result }) return result } - return { command, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)), + async function recorded(name, method, path, createBody, token = context.token, headers = {}) { + const previous = state.commands?.[name] + if (previous) { + check(previous.status === 'DONE', `海外命令 ${name} 结果未知;停止重发并保留检查点`) + check(previous.request && previous.binding === fingerprint(previous.request) + && previous.request.method === method && previous.request.path === path + && fingerprint(previous.request.headers) === fingerprint(headers) && previous.request.file === null, + `海外命令 ${name} 缺少原生原请求或检查点不符`) + return command(name, method, path, previous.request.body, token, headers) + } + return command(name, method, path, await createBody(), token, headers) + } + return { command, recorded, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)), readCurrentReceivingSession: async token => { const response = await context.request('GET', `${PDA}/receiving-containers/current`, undefined, token) // Only the initial current-session probe treats the service's absent-session business code as empty. @@ -51,6 +69,9 @@ export function validateOverseasInput(input, domestic) { for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`) for (const key of ['vehicleId', 'driverId']) check(/^[1-9]\d*$/.test(input.resources?.[key] || ''), `缺少既有 ${key}`) check(Boolean(input.resources.carrierName), '缺少既有车队名称') + check(!(input.loadingMeasurementFile != null && input.loadingMeasurements != null), '真实测量文件与内联测量不能同时提供') + if (input.loadingMeasurementFile != null) check(typeof input.loadingMeasurementFile === 'string' && isAbsolute(input.loadingMeasurementFile), '真实测量文件须为预先绑定的明确绝对路径') + if (input.loadingMeasurementFile != null || input.loadingMeasurements != null) check(['test', 'gray'].includes(input.environment?.mode), '真实测量缺少本次TEST或GRAY环境绑定') for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`) for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`) if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks) @@ -64,7 +85,12 @@ export async function runOverseasStage({ input, domestic, context, checkpoint }) check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话') const { state, save } = checkpoint check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点') - const { read, lookup, command, readCurrentReceivingSession } = overseasCommands(context, checkpoint) + check(!state.dispatchProtocol || state.dispatchProtocol === DISPATCH_PROTOCOL, '海外排车检查点协议不符') + const oldDispatchCommands = ['dispatch', 'dispatch-vehicle', 'transfer', 'trip', 'trip-assign', 'trip-bind', 'trip-plan', 'trip-review', 'pickComplete', 'loading', 'loading-review', 'depart'] + check(state.dispatchProtocol === DISPATCH_PROTOCOL || !oldDispatchCommands.some(name => state.commands?.[name]) + && !Object.keys(state.commands || {}).some(name => name.startsWith('pick-')), '旧排车协议检查点不能映射原生主单,请人工核对原业务') + if (!state.dispatchProtocol) { await save({ dispatchProtocol: DISPATCH_PROTOCOL }); state.dispatchProtocol = DISPATCH_PROTOCOL } + const { read, lookup, command, recorded, readCurrentReceivingSession } = overseasCommands(context, checkpoint) const pdaToken = context.overseasPdaToken const remark = `RELEASE-ACCEPTANCE:${input.batch}` const key = name => `${input.batch}-${name}` @@ -78,7 +104,7 @@ export async function runOverseasStage({ input, domestic, context, checkpoint }) const vehicle = await read(`${BASE}/master-data/vehicles/${input.resources.vehicleId}`) const driver = await read(query(`${BASE}/fleet/drivers/${input.resources.driverId}`, { expectedEnd: schedule.expectedDriverEndAt })) check(id(vehicle.id) === input.resources.vehicleId && vehicle.region === 'OVERSEAS' && vehicle.status === 'IN_SERVICE', '既有海外车辆不可用') - check(id(driver.id) === input.resources.driverId && driver.qualification?.canDispatch === true, '既有司机证照/审核不满足本次出车') + check(id(driver.id) === input.resources.driverId && (state.commands?.dispatch || driver.qualification?.canDispatch === true), '既有司机证照/审核不满足本次出车') let sources for (let attempt = 0; attempt < 60; attempt++) { const page = await read(query(`${BASE}/orders`, { containerNo: domestic.containerNo, page: '1', size: '200' })) @@ -160,84 +186,170 @@ export async function runOverseasStage({ input, domestic, context, checkpoint }) }, undefined, { 'Idempotency-Key': key('empty-return') }) check(returned.status === 'EMPTY_RETURNED', '实际还柜未完成') } - let dispatch = state.commands?.dispatch?.result - if (!dispatch) { - const candidates = await read(`${BASE}/dispatches/candidates?sourceStage=RECEIVED`) - const matches = candidates.filter(row => row.sourceStage === 'RECEIVED' && row.inboundCode === domestic.inboundCode - && id(row.customerId) === input.customerId && id(row.overseasWarehouseId) === input.warehouseId) - check(matches.length === 1 && Number(matches[0].packageCount) === input.packageCount, '本票排车候选不唯一或数量不符') - dispatch = await command('dispatch', 'POST', `${BASE}/dispatches/pre-arrange`, { planPalletIds: matches.map(row => id(row.planPalletId)) }) - } + const dispatch = await recorded('dispatch', 'POST', `${BASE}/dispatches`, async () => { + const candidates = await read(query(`${BASE}/dispatches/candidates`, { warehouseId: input.warehouseId, orderNo: domestic.inboundCode })) + check(candidates.complete === true && Array.isArray(candidates.candidates), '本票原生排车候选不完整') + const matches = candidates.candidates.filter(row => row.inboundCode === domestic.inboundCode && id(row.customerId) === input.customerId + && id(row.warehouseId) === input.warehouseId && row.palletNo === pallet.palletNo) + check(matches.length === 1 && candidates.candidates.length === 1 && Number(matches[0].availablePackages) === input.packageCount + && matches[0].selection?.sourceType === 'INBOUND_PLAN' && matches[0].selection.planPalletId + && matches[0].selection.sourceVersion && matches[0].selection.sourceVersion === matches[0].sourceVersion + && matches[0].blockReasons?.length === 0, '本票原生排车候选身份、来源版本或数量不符') + return { idempotencyKey: key('dispatch'), warehouseId: input.warehouseId, + vehicle: { mode: 'MASTER', vehicleId: input.resources.vehicleId }, + driver: { mode: 'MASTER', driverId: input.resources.driverId, evidence: { expectedTaskEndAt: schedule.expectedDriverEndAt } }, + plannedDepartureAt: schedule.plannedDepartureAt, estimatedArrivalAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(), + remark, cargo: matches.map(row => row.selection), batchAllocations: [] } + }) const dispatchNo = dispatch.dispatchNo - check(Boolean(dispatchNo), '排车单号缺失') - const transport = { vehiclePlate: vehicle.plateNumber, driverName: driver.details?.name, - driverPhone: driver.details?.phone, carrierName: input.resources.carrierName, estimatedArrivalAt: schedule.arrival } - await command('dispatch-vehicle', 'PUT', `${BASE}/dispatches/${dispatchNo}`, transport) - const transfer = await command('transfer', 'POST', query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), { - idempotencyKey: key('transfer'), plannedDeliveryAt: schedule.arrival, deliveryMethod: 'TRUCK_PALLET', - waybillNo: key('WAYBILL'), carrierName: transport.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', - vehicleType: 'TRUCK', vehicleSize: '15托', driverName: transport.driverName, driverPhone: transport.driverPhone, - vehiclePlate: transport.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark, - fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark }], - }, context.merchantToken) - check(transfer.deliveries?.length === 1 && transfer.outboundNo, '本票未形成唯一出库/派送单') - let trip = await command('trip', 'POST', `${BASE}/fleet/trips`, { warehouseId: input.warehouseId, - plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, - stops: [{ name: '验收起运仓' }, { name: '待匹配本票目的地' }], remark, idempotencyKey: key('trip') }) - const tripNo = trip.trip.tripNo, tripPath = `${BASE}/fleet/trips/${tripNo}` - trip = await command('trip-assign', 'POST', `${tripPath}/assignment`, { recordVersion: trip.trip.recordVersion, - vehicleId: input.resources.vehicleId, driverId: input.resources.driverId, reason: remark }) - trip = await command('trip-bind', 'POST', `${tripPath}/dispatches/bind`, { recordVersion: trip.trip.recordVersion, dispatchNo, reason: remark }) - let stops = state.stops - if (!stops) { - const loading = await read(`${tripPath}/loading`) - check(loading.cargo?.length > 0 && loading.cargo.every(row => row.destinationKey), '本票装载缺少真实目的地') - stops = [{ name: '验收起运仓' }, ...new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])).values()] - await save({ stops }); state.stops = stops + check(dispatchNo && dispatch.status === 'ACTIVE' && dispatch.stage === 'BEFORE_LOADING', '本票原生建单回执不符') + const dispatchPath = `${BASE}/dispatches/${encodeURIComponent(dispatchNo)}`, pdaDispatch = `${PDA}/dispatches/${encodeURIComponent(dispatchNo)}` + const ownMain = async () => { + const detail = await read(dispatchPath), items = await read(`${dispatchPath}/items`) + check(detail.dispatchNo === dispatchNo && detail.status === 'ACTIVE' && detail.orderDetails?.length > 0 + && detail.orderDetails.every(row => row.inboundCode === domestic.inboundCode && id(row.customerId) === input.customerId) + && id(detail.vehicleInfo?.vehicle?.vehicleId) === input.resources.vehicleId && id(detail.vehicleInfo?.driver?.driverId) === input.resources.driverId + && items.dispatchNo === dispatchNo && items.items?.length > 0 && items.items.every(row => row.inboundCode === domestic.inboundCode + && id(row.customerId) === input.customerId && row.palletNo === pallet.palletNo) + && Number(detail.counts?.packageCount) === input.packageCount, '原生主单含其他票、客户、资源或箱数不符') + version(detail.recordVersion); return { detail, items: items.items } } - trip = await command('trip-plan', 'PUT', `${tripPath}/plan`, { recordVersion: trip.trip.recordVersion, - plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, stops, reason: remark }) - trip = await command('trip-review', 'POST', `${tripPath}/dispatches/${dispatchNo}/review`, { recordVersion: trip.trip.recordVersion, decision: 'APPROVED' }) - const pdaDispatch = `${PDA}/dispatches/${dispatchNo}` - if (!state.commands?.pickComplete) { - const picking = await read(pdaDispatch, pdaToken) - const exactDispatch = await read(`${BASE}/dispatches/${dispatchNo}`) - check(exactDispatch.items?.length > 0 && exactDispatch.items.every(row => row.inboundCode === domestic.inboundCode) - && picking.items?.length === exactDispatch.items.length - && picking.items.every(row => exactDispatch.items.some(item => id(item.id) === id(row.dispatchItemId))), 'PDA拣货包含其他票') - for (const item of picking.items) { - let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) - for (const position of item.positions || []) { - if (remaining <= 0) break - const packages = Math.min(remaining, Number(position.availablePackages)) - if (!(packages > 0)) continue - await command(`pick-${item.dispatchItemId}-${position.id}`, 'POST', `${pdaDispatch}/items/${item.dispatchItemId}/pick`, { - positionId: id(position.id), packages, productQuantity: packages, idempotencyKey: key(`pick-${position.id}`), remark, - }, pdaToken) - remaining -= packages - } - check(remaining === 0, '本票库存不足以完成拣货') + await ownMain() + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) await recorded(action, 'POST', `${dispatchPath}/${action}`, async () => { + const { detail } = await ownMain() + check(detail.stage === 'BEFORE_LOADING', '本票原生审核阶段不符') + return { recordVersion: detail.recordVersion, idempotencyKey: key(action), + ...(action === 'submit-review' ? { remark } : { decision: 'APPROVED', reason: remark }) } + }) + const candidates = []; let total + for (let page = 1; ; page++) { + const rows = await read(query(`${BASE}/fleet/trips`, { warehouseId: input.warehouseId, keyword: vehicle.plateNumber, page: String(page), size: '200' })) + check(Array.isArray(rows.records) && Number.isSafeInteger(Number(rows.total)) && Number(rows.total) >= 0 + && Number(rows.page) === page && Number(rows.size) === 200, '自动车次分页缺失或不完整') + total ??= Number(rows.total) + check(Number(rows.total) === total && (rows.records.length > 0 || candidates.length === total), '自动车次分页变化或缺页') + for (const row of rows.records) { check(row.tripNo && !candidates.some(saved => saved.tripNo === row.tripNo), '自动车次业务号缺失或重复'); candidates.push(row) } + if (candidates.length >= total) break + } + check(candidates.length === total, '自动车次分页总数不一致') + const bound = [] + for (const row of candidates.filter(row => id(row.vehicleId) === input.resources.vehicleId && id(row.driverId) === input.resources.driverId + && id(row.warehouseId) === input.warehouseId && row.vehiclePlate === vehicle.plateNumber && row.tripPurpose === 'DELIVERY')) { + const detail = await read(`${BASE}/fleet/trips/${encodeURIComponent(row.tripNo)}`) + if (detail.dispatches?.some(binding => binding.dispatchNo === dispatchNo)) { + check(detail.trip?.tripNo === row.tripNo && id(detail.trip.vehicleId) === input.resources.vehicleId + && id(detail.trip.driverId) === input.resources.driverId && detail.dispatches.length === 1, '自动车次绑定混入其他排车或资源') + bound.push(detail) } - await command('pickComplete', 'POST', `${pdaDispatch}/complete`, { idempotencyKey: key('pick-complete') }, pdaToken) } - let loadingResult = state.commands?.loading?.result - if (!loadingResult) { - const loading = await read(`${tripPath}/loading`) - check(!loading.blockReason, '整车装载未就绪') - loadingResult = await command('loading', 'POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, - manifestHash: loading.manifestHash, pallets: controlledPalletMeasurements(loading.cargo, stops), reason: `${remark} 合成验收测量` }) + check(bound.length === 1, '本票主单自动Fleet资源记录不唯一') + const trip = bound[0], tripNo = trip.trip.tripNo + if (!state.pickingVerified) { + const { detail, items } = await ownMain() + const picking = await read(pdaDispatch, pdaToken) + check(detail.stage === 'LOADING' && picking.dispatchNo === dispatchNo && picking.status === 'ACTIVE' && picking.stage === 'LOADING' + && id(picking.warehouseId) === input.warehouseId && picking.items?.length === items.length + && picking.items.every(row => items.some(item => id(item.dispatchItemId) === id(row.dispatchItemId) + && id(row.customerId) === input.customerId && row.palletNo === pallet.palletNo)), 'PDA拣货包含其他票或尚未通过两级审核') + if (!state.pickSources) { + const pickSources = picking.items.flatMap(item => (item.positions || []).map(position => ({ dispatchItemId: id(item.dispatchItemId), positionId: id(position.positionId) }))) + check(pickSources.length > 0 && pickSources.every(row => /^[1-9]\d*$/.test(row.positionId)), '本票真实库存位置缺失') + await save({ pickSources }); state.pickSources = pickSources + } + for (const source of state.pickSources) { + const name = `pick-${source.dispatchItemId}-${source.positionId}` + if (state.commands?.[name]) { + await recorded(name, 'POST', `${pdaDispatch}/items/${source.dispatchItemId}/pick`, () => { throw new Error('成功扫码不得重新构造原请求') }, pdaToken) + continue + } + const current = await read(pdaDispatch, pdaToken), item = current.items?.find(row => id(row.dispatchItemId) === source.dispatchItemId) + check(current.status === 'ACTIVE' && current.stage === 'LOADING' && item && id(item.customerId) === input.customerId, '扫码前本票任务身份变化') + const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + if (remaining === 0) continue + const position = item.positions?.find(row => id(row.positionId) === source.positionId) + const packages = Math.min(remaining, Number(position?.availablePackages)) + check(Number.isSafeInteger(packages) && packages > 0, '本票库存不足以完成拣货') + await recorded(name, 'POST', `${pdaDispatch}/items/${source.dispatchItemId}/pick`, async () => ({ recordVersion: version(current.recordVersion), + positionId: source.positionId, cartonMark: null, packages, productQuantity: packages, idempotencyKey: key(name), remark }), pdaToken) + } + const reconciled = await read(pdaDispatch, pdaToken) + check(reconciled.status === 'ACTIVE' && reconciled.stage === 'LOADING' && reconciled.pickingReconciled === true + && Number(reconciled.counts?.packageCount) === input.packageCount && Number(reconciled.counts?.pickedPackageCount) === input.packageCount + && reconciled.items.every(item => Number(item.scheduledPackages) === Number(item.pickedPackages)), '本票真实扫码未核平或提前越过装车中') + await save({ pickingVerified: true }); state.pickingVerified = true } - const reviewed = await command('loading-review', 'POST', `${tripPath}/loading-review`, { recordVersion: loadingResult.recordVersion, decision: 'APPROVED' }) - if (!state.commands?.depart) { - const ready = await read(pdaDispatch, pdaToken) - check(ready.status === 'COMPLETED' && ready.canDepart === true, 'PDA发车门禁未通过') - } - await command('depart', 'POST', `${pdaDispatch}/depart`, { recordVersion: reviewed.trip.recordVersion, idempotencyKey: key('depart'), remark }, pdaToken) - const outbound = await read(`${BASE}/outbound-orders/${transfer.outboundNo}`) - check(outbound.outbound?.status === 'OUTBOUNDED' && outbound.pallets?.length > 0 - && outbound.pallets.every(row => row.inboundCode === domestic.inboundCode), '本票未真实完成出库') + const loaded = await recorded('loading-complete', 'POST', `${pdaDispatch}/loading-complete`, async () => { + const preview = await read(`${pdaDispatch}/loading`, pdaToken) + check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'LOADING' + && preview.pickingReconciled === true && preview.loadingComplete?.allowed === true && preview.manifestHash + && preview.pallets?.length === 1 && id(preview.pallets[0].warehousePalletId) === id(pallet.warehousePalletId), '本票实际装载预览未就绪或包含其他托盘') + let actual = input.loadingMeasurements, sourceSha256 = null + if (input.loadingMeasurementFile != null) { + let bytes + try { bytes = await readFile(input.loadingMeasurementFile) } + catch { throw new Error('本票已就绪等待真实测量:请在预先绑定的loadingMeasurementFile绝对路径提供本轮JSON回执,保留同一输入与检查点续跑') } + sourceSha256 = createHash('sha256').update(bytes).digest('hex') + try { actual = JSON.parse(bytes.toString('utf8')) } + catch { throw new Error('本轮真实测量文件不是有效JSON,停止装车写入') } + } else if (actual != null) sourceSha256 = createHash('sha256').update(JSON.stringify(actual)).digest('hex') + if (actual != null) { + check(actual.environment === input.environment.mode.toUpperCase() && actual.batch === input.batch && actual.dispatchNo === dispatchNo + && Array.isArray(actual.pallets) && actual.pallets.length === preview.pallets.length, '本轮实际测量回执环境、批次、排车或托盘绑定不符') + for (const measured of actual.pallets) { + check(preview.pallets.some(row => id(row.warehousePalletId) === id(measured.warehousePalletId)), '本轮实际测量包含其他托盘') + for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) check(measured[field] != null && measured.evidence?.some(evidence => evidence.actual === true + && evidence.field === field && evidence.sourceType && evidence.sourceBusinessNo && Number.isFinite(Date.parse(evidence.observedAt)) + && Number(evidence.value) === Number(measured[field])), `本轮实际测量${field}缺少逐字段actual来源证据`) + } + } + const measurements = controlledPalletMeasurements(preview.pallets, trip.trip.stops, actual?.pallets) + const loadingMeasurementEvidence = { environment: input.environment?.mode?.toUpperCase() ?? null, batch: input.batch, dispatchNo, + source: input.loadingMeasurementFile != null ? 'EXPLICIT_FILE' : actual != null ? 'INLINE' : 'NATIVE_PREVIEW', + file: input.loadingMeasurementFile ?? null, sourceSha256, receipt: actual ?? null, manifestHash: preview.manifestHash, measurements } + if (state.loadingMeasurementEvidence) check(fingerprint(state.loadingMeasurementEvidence) === fingerprint(loadingMeasurementEvidence), + '已冻结的本轮真实测量发生变化,禁止替换原证据或重造装车命令') + else { await save({ loadingMeasurementEvidence }); state.loadingMeasurementEvidence = loadingMeasurementEvidence } + return { recordVersion: version(preview.recordVersion), idempotencyKey: key('loading-complete'), manifestHash: preview.manifestHash, + measurements, remark: `${remark}仅采用真实来源测量,不从计费或计划常量推算` } + }, pdaToken) + check(loaded.dispatchNo === dispatchNo && loaded.status === 'ACTIVE' && loaded.stage === 'AFTER_LOADING' + && loaded.result?.confirmedLoad?.pallets?.length === 1, '本票显式装车回执不符') + const transfer = await recorded('transfer', 'POST', `${dispatchPath}/transfer-delivery`, async () => { + const preview = await read(`${dispatchPath}/delivery-arrangement`) + check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' + && preview.arrange?.allowed === true && preview.cargoHash && preview.loadingCompletionHash && preview.groups?.length === 1 + && id(preview.groups[0].customerId) === input.customerId && preview.groups[0].cargo?.length > 0 + && preview.groups[0].cargo.every(row => id(row.customerId) === input.customerId && row.inboundCode === domestic.inboundCode) + && preview.groups[0].blockReasons?.length === 0 && preview.groups[0].externalQuotes?.length === 0, '本票派送安排不完整、混入其他票或存在外接约定') + return { recordVersion: version(preview.recordVersion), idempotencyKey: key('transfer'), cargoHash: preview.cargoHash, + loadingCompletionHash: preview.loadingCompletionHash, groups: preview.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, + plannedDeliveryAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: key('WAYBILL'), + carrierName: input.resources.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本票真实实载', + appointmentAccount: 'SELF', sellerName: null, remark, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, + feeCoverage: [], fees: (group.requiredServices || []).map(feeType => ({ feeType, feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark })) })) } + }) + check(transfer.dispatchNo === dispatchNo && transfer.status === 'ACTIVE' && transfer.stage === 'AFTER_LOADING' + && transfer.result?.deliveries?.length === 1 && transfer.result.outbounds?.length === 1 + && id(transfer.result.deliveries[0].customerId) === input.customerId && id(transfer.result.outbounds[0].customerId) === input.customerId + && transfer.result.deliveries[0].outboundNo === transfer.result.outbounds[0].outboundNo + && Number(transfer.result.deliveries[0].packageCount) === input.packageCount, '本票未形成原生唯一客户出库和派送') + const outboundNo = transfer.result.outbounds[0].outboundNo, deliveryNo = transfer.result.deliveries[0].deliveryNo + const departed = await recorded('depart', 'POST', `${pdaDispatch}/depart`, async () => { + const ready = await read(pdaDispatch, pdaToken), { detail } = await ownMain() + check(ready.dispatchNo === dispatchNo && ready.status === 'ACTIVE' && ready.stage === 'AFTER_LOADING' + && ready.pickingReconciled === true && ready.actions?.DEPART?.allowed === true && detail.actions?.DEPART?.allowed === true + && id(ready.recordVersion) === id(detail.recordVersion), 'PDA及平台当前主版本发车门禁未通过') + return { recordVersion: version(ready.recordVersion), idempotencyKey: key('depart'), remark } + }, pdaToken) + check(departed.dispatchNo === dispatchNo && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' + && Number(departed.result?.shippedPackages) === input.packageCount && departed.result.deliveries?.length === 1 + && departed.result.deliveries[0].deliveryNo === deliveryNo, '本票原生发车结果不符') + const outbound = await read(query(`${BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId: input.customerId })) + check(id(outbound.outbound?.customerId) === input.customerId && outbound.outbound.dispatchNo === dispatchNo + && outbound.outbound.status === 'OUTBOUNDED' && outbound.pallets?.length > 0 + && outbound.pallets.every(row => row.inboundCode === domestic.inboundCode) && Number(outbound.outbound.packageCount) === input.packageCount, '本票未真实完成出库') const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId, - tailOrderId, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: transfer.deliveries[0].deliveryNo, + tailOrderId, dispatchNo, outboundNo, deliveryNo, tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY' } await save({ result }); return result } diff --git a/scripts/prod-release-overseas-stage.test.mjs b/scripts/prod-release-overseas-stage.test.mjs index 80cbf4dd6..88e4d515c 100644 --- a/scripts/prod-release-overseas-stage.test.mjs +++ b/scripts/prod-release-overseas-stage.test.mjs @@ -1,8 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { mkdtemp, writeFile, rm, readFile, rmdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs' -const input = { batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1, +const input = { environment: { mode: 'test', apiBase: 'https://test.example.invalid/api' }, batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1, resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } } const domestic = { containerBookingId: '5', sourceInboundId: '6', orderId: '7', containerNo: 'TEST0000001', inboundCode: 'IN-TEST', orderNo: 'ORDER-TEST', cartonMarks: ['CARTON-TEST'] } @@ -57,43 +61,81 @@ test('current receiving probe preserves auth, transport and other business error } }) -function fixture({ foreignSource = false, destinationType = 'OVERSEAS' } = {}) { +function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingActual = false, foreignCargo = false, + foreignTrip = false, candidateComplete = true, loseCommand = null } = {}) { const checkpoint = journal(), calls = [] const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0, inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] } - const trip = { trip: { tripNo: 'TRIP-TEST', recordVersion: 1, stops: [] } } - const cargo = [{ warehousePalletId: '9', palletNo: 'PLT-TEST', destinationKey: 'DEST', destinationLabel: 'Destination' }] + const dispatchNo = 'DISPATCH-TEST', dispatchPath = `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, pdaPath = `/api/pda/v1/last-leg/dispatches/${dispatchNo}` + let stage = 'BEFORE_LOADING', recordVersion = 0, picked = 0, created = false, receivingRead = 0 + const stops = [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:DEST' }] + const counts = () => ({ packageCount: 1, pickedPackageCount: picked, shippedPackageCount: stage === 'DELIVERING' ? 1 : 0 }) + const nativeResult = result => ({ dispatchNo, status: 'ACTIVE', stage, recordVersion, counts: counts(), result }) + const ownItem = () => ({ dispatchItemId: '12', customerId: foreignCargo ? '99' : '1', inboundCode: domestic.inboundCode, + warehousePalletId: '9', palletNo: 'PLT-TEST', scheduledPackages: 1, pickedPackages: picked, + positions: [{ positionId: '13', availablePackages: 1 - picked }] }) + const delivery = { customerId: '1', outboundNo: 'OUT-TEST', deliveryNo: 'DELIVERY-TEST', packageCount: 1, destination: { key: 'FBA:DEST' } } + const group = { groupKey: 'GROUP-1', groupCargoHash: 'group-hash', customerId: '1', destination: { key: 'FBA:DEST' }, + cargo: [{ customerId: '1', inboundCode: domestic.inboundCode }], requiredServices: ['DELIVERY_FEE'], blockReasons: [], + externalQuotes: [], externalFreightHash: 'freight-hash', feeCoverageCargoHash: 'coverage-hash' } + const trip = () => ({ trip: { tripNo: 'TRIP-TEST', vehicleId: '3', driverId: '4', warehouseId: '2', vehiclePlate: 'TEST', + tripPurpose: 'DELIVERY', recordVersion: 1, stops }, dispatches: [{ dispatchNo: foreignTrip ? 'OTHER-DISPATCH' : dispatchNo }] }) + const measured = () => ({ warehousePalletId: '9', palletNo: 'PLT-TEST', scheduledPackages: 1, pickedPackages: picked, + palletSpaces: missingActual ? null : 2, weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1, + evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].filter(field => !missingActual || field !== 'palletSpaces').map(field => ({ field, actual: true, + sourceType: 'WAREHOUSE_MEASUREMENT', sourceBusinessNo: 'RECEIPT-TEST', observedAt: '2026-10-19T10:00:00Z', + value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }) const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {}, requestWithBody: async (method, path, form, token) => { calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' }) }, request: async (method, path, body, token) => { - calls.push({ method, path, body, token }) + calls.push({ method, path, body: body == null ? body : structuredClone(body), token }) if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' }) - if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: true }, details: { name: 'Driver', phone: '10000000000' } }) + if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: !created }, details: { name: 'Driver', phone: '10000000000' } }) if (path.includes('/orders?containerNo=')) return ok({ records: [{ sourceContainerBookingId: '5', customerId: foreignSource ? '99' : '1', sourceInboundId: '6', inboundCode: domestic.inboundCode, overseasWarehouseId: '2', id: '8' }] }) if (path.endsWith('/container-arrival-times/query')) return ok([{ sourceContainerBookingId: '5', version: 0 }]) if (path.includes('/container-preview?')) return ok({ ready: true, totalOrderCount: 1, pendingOrderCount: 1, snapshotVersion: 1, snapshotHash: 'hash' }) if (path.endsWith('/convert-container')) return ok([{ lines: [{ inboundCode: domestic.inboundCode }], pallets: [{ palletNo: 'PLT-TEST', warehousePalletId: '9' }] }]) - if (path.endsWith('/current') && method === 'GET') return calls.filter(c => c.path.endsWith('/current')).length === 1 - ? { http: 200, body: { code: 'A0404' } } : ok(session) + if (path.endsWith('/current') && method === 'GET') return ++receivingRead === 1 ? { http: 200, body: { code: 'A0404' } } : ok(session) if (path.includes('/receiving-containers/TEST0000001?')) return ok(session) if (path.endsWith('/pallets/resolve')) return ok({ compatible: true }) if (path.endsWith('/cartons/resolve')) return ok({ inboundCode: domestic.inboundCode }) if (path.endsWith('/terminal-drayages/5')) return ok({ containerBookingId: '5', status: 'UNLOADED', version: 1, unloadingCompletedAt: '2026-10-12T12:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }) if (path.endsWith('/empty-return')) return ok({ status: 'EMPTY_RETURNED' }) - if (path.endsWith('/candidates?sourceStage=RECEIVED')) return ok([{ sourceStage: 'RECEIVED', inboundCode: domestic.inboundCode, - customerId: '1', overseasWarehouseId: '2', packageCount: 1, planPalletId: '11' }]) - if (path.endsWith('/pre-arrange')) return ok({ dispatchNo: 'DISPATCH-TEST' }) - if (path.includes('/merchant/v1/last-leg/delivery-orders?')) return ok({ outboundNo: 'OUT-TEST', deliveries: [{ deliveryNo: 'DELIVERY-TEST' }] }) - if (path.endsWith('/loading') && method === 'GET') return ok({ cargo, recordVersion: 1, manifestHash: 'manifest' }) - if (path.endsWith('/loading') && method === 'POST') return ok({ recordVersion: 2 }) - if (path.includes('/fleet/trips')) return ok(trip) - if (path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST') return ok({ status: 'COMPLETED', canDepart: true, - items: [{ dispatchItemId: '12', scheduledPackages: 1, pickedPackages: 0, positions: [{ id: '13', availablePackages: 1 }] }] }) - if (path === '/api/platform/v1/last-leg/dispatches/DISPATCH-TEST' && method === 'GET') return ok({ items: [{ id: '12', inboundCode: domestic.inboundCode }] }) - if (path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED' }, pallets: [{ inboundCode: domestic.inboundCode }] }) + if (path.includes('/dispatches/candidates?')) return ok({ complete: candidateComplete, candidates: [{ inboundCode: domestic.inboundCode, + customerId: '1', warehouseId: '2', palletNo: 'PLT-TEST', availablePackages: 1, sourceVersion: 'real-source', blockReasons: [], + selection: { sourceType: 'INBOUND_PLAN', planPalletId: '11', sourceVersion: 'real-source' } }] }) + if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { created = true; return ok(nativeResult(null)) } + if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) return ok({ records: [trip().trip], total: 1, page: 1, size: 200 }) + if (path.endsWith('/fleet/trips/TRIP-TEST')) return ok(trip()) + if (path === dispatchPath && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1, + orderDetails: [{ inboundCode: domestic.inboundCode, customerId: '1' }], + vehicleInfo: { vehicle: { vehicleId: '3' }, driver: { driverId: '4' } }, actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } }) + if (path === `${dispatchPath}/items`) return ok({ dispatchNo, recordVersion, items: [ownItem()] }) + if (path === pdaPath && method === 'GET') return ok({ ...nativeResult(null), warehouseId: '2', pickingReconciled: picked === 1, + items: [ownItem()], actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } }) + if (path === `${pdaPath}/loading` && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1, + loadingComplete: { allowed: picked === 1 }, manifestHash: 'real-manifest', pallets: [measured()] }) + if (path === `${dispatchPath}/delivery-arrangement`) return ok({ ...nativeResult(null), arrange: { allowed: true }, + cargoHash: 'real-cargo', loadingCompletionHash: 'real-load', groups: [group] }) + if (method === 'POST' && (path.startsWith(`${dispatchPath}/`) || path.startsWith(`${pdaPath}/`))) { + assert.equal(body.recordVersion, recordVersion, `must use current main version: ${path}`) + recordVersion++ + if (path.endsWith('/loading-review')) stage = 'LOADING' + if (path.endsWith('/pick')) { assert.equal(stage, 'LOADING'); picked = 1 } + if (path.endsWith('/loading-complete')) { stage = 'AFTER_LOADING'; return ok(nativeResult({ confirmedLoad: { pallets: body.measurements } })) } + if (path.endsWith('/transfer-delivery')) { + assert.equal(token, 'admin'); assert.equal(stage, 'AFTER_LOADING'); assert.equal(body.groups.length, 1) + assert.equal(body.groups[0].fees[0].currency, 'USD'); assert.equal(body.groups[0].fees[0].amount, 10) + return ok(nativeResult({ deliveries: [delivery], outbounds: [{ customerId: '1', outboundNo: 'OUT-TEST' }] })) + } + if (path.endsWith('/depart')) { stage = 'DELIVERING'; return ok(nativeResult({ shippedPackages: 1, deliveries: [delivery] })) } + if (path.endsWith(loseCommand || 'NOT_A_COMMAND')) throw Error('simulated response loss') + return ok(nativeResult(null)) + } + if (path.includes('/outbound-orders/')) return ok({ outbound: { customerId: '1', dispatchNo, status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ inboundCode: domestic.inboundCode }] }) if (method === 'GET') assert.fail(`unexpected GET ${path}`) return ok({}) } } @@ -156,3 +198,169 @@ test('signed single ticket closes its trip and releases only its existing resour assert.equal(writes.length, 3) assert.deepEqual(writes.map(row => row.body.recordVersion), [1, 2, 3]) }) + + +test('native successful continuation restores original dynamic requests without sending any business write again', async () => { + const f = fixture() + const result = await runOverseasStage(f) + const writes = f.calls.filter(row => row.method !== 'GET').length + const firstSubmit = structuredClone(f.checkpoint.state.commands['submit-review'].request) + assert.equal((await runOverseasStage(f)).dispatchNo, result.dispatchNo) + assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes - 1) + assert.deepEqual(f.checkpoint.state.commands['submit-review'].request, firstSubmit) + assert.equal(firstSubmit.body.recordVersion, 0) + assert.equal(f.calls.filter(row => row.path.endsWith('/loading-complete')).length, 1) +}) + +test('completed recorded commands retain original payload instead of reading a new current version', async () => { + const checkpoint = journal(); let calls = 0 + const helper = overseasCommands({ request: async (_method, _path, body) => { calls++; return ok({ recordVersion: body.recordVersion + 1 }) } }, checkpoint) + await helper.recorded('review', 'POST', '/native/review', async () => ({ recordVersion: 0, idempotencyKey: 'original' })) + const saved = checkpoint.state.commands.review + assert.equal(saved.request.body.recordVersion, 0) + assert.deepEqual(await helper.recorded('review', 'POST', '/native/review', () => assert.fail('must not rebuild with current version')), { recordVersion: 1 }) + assert.equal(calls, 1) + saved.request.body.recordVersion = 2 + await assert.rejects(helper.recorded('review', 'POST', '/native/review', () => assert.fail('must stop')), /检查点不符/) +}) + +test('legacy flat dispatch checkpoint is explicitly rejected before network without converting old replies', async () => { + for (const name of ['dispatch', 'trip-bind', 'pickComplete', 'loading-review', 'depart']) { + const f = fixture(); f.checkpoint.state.commands = { [name]: { status: 'DONE', result: { dispatchNo: 'OLD' } } } + await assert.rejects(runOverseasStage(f), /旧排车协议/) + assert.equal(f.calls.length, 0) + } +}) + +test('real missing pallet spaces blocks loading before any arrangement or departure rather than fabricating one', async () => { + const f = fixture({ missingActual: true }) + await assert.rejects(runOverseasStage(f), /palletSpaces/) + assert.ok(f.calls.some(row => row.path.endsWith('/pick'))) + assert.equal(f.calls.filter(row => /loading-complete|transfer-delivery|\/depart$/.test(row.path)).length, 0) + assert.equal(f.checkpoint.state.commands['loading-complete'], undefined) +}) + +test('incomplete candidates, foreign cargo and wrong auto-trip binding cannot enter actual picking', async () => { + for (const options of [{ candidateComplete: false }, { foreignCargo: true }, { foreignTrip: true }]) { + const f = fixture(options) + await assert.rejects(runOverseasStage(f), /候选不完整|主单含其他票|自动Fleet资源记录不唯一/) + assert.equal(f.calls.filter(row => row.path.endsWith('/pick')).length, 0) + assert.equal(f.calls.filter(row => row.method === 'POST' && /\/fleet\/trips$|\/dispatches\/bind$/.test(row.path)).length, 0) + } +}) + +test('actual native loading keeps measured 2 spaces, 17.75kg and 0.072m3 and distinct explicit stages', async () => { + const f = fixture(); await runOverseasStage(f) + const load = f.calls.find(row => row.path.endsWith('/loading-complete')) + assert.deepEqual(load.body.measurements, [{ warehousePalletId: '9', palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1 }]) + const create = f.calls.find(row => row.method === 'POST' && row.path === '/api/platform/v1/last-leg/dispatches') + assert.deepEqual(create.body.vehicle, { mode: 'MASTER', vehicleId: '3' }) + assert.equal(create.body.driver.mode, 'MASTER'); assert.equal(create.body.driver.driverId, '4') + assert.equal(create.body.driver.evidence.expectedTaskEndAt, f.checkpoint.state.schedule.expectedDriverEndAt) + assert.equal(Object.hasOwn(create.body, 'customerId'), false) + const pick = f.calls.findIndex(row => row.path.endsWith('/pick')), loading = f.calls.indexOf(load), arrangement = f.calls.findIndex(row => row.path.endsWith('/transfer-delivery')), depart = f.calls.findIndex(row => row.path.endsWith('/depart')) + assert.ok(pick < loading && loading < arrangement && arrangement < depart) + assert.equal(f.calls.filter(row => row.path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST/complete').length, 0) + assert.equal(f.calls.filter(row => row.path.includes('/merchant/v1/last-leg/delivery-orders')).length, 0) +}) + + +test('lost native review result preserves exact INTENT request and prevents all continuation HTTP', async () => { + const f = fixture({ loseCommand: '/dispatch-review' }) + await assert.rejects(runOverseasStage(f), /response loss/) + const intent = f.checkpoint.state.commands['dispatch-review'] + assert.equal(intent.status, 'INTENT') + assert.equal(intent.request.body.recordVersion, 1) + assert.equal(intent.request.body.idempotencyKey, `${input.batch}-dispatch-review`) + const calls = f.calls.length + await assert.rejects(runOverseasStage(f), /结果未知/) + assert.equal(f.calls.length, calls) +}) + +test('native successful checkpoint without its original request cannot rebuild a versioned command', async () => { + const checkpoint = journal(); checkpoint.state.commands = { review: { status: 'DONE', binding: 'old-hash', result: { recordVersion: 9 } } } + const helper = overseasCommands({ request: () => assert.fail('no HTTP') }, checkpoint) + await assert.rejects(helper.recorded('review', 'POST', '/native/review', () => assert.fail('no rebuild')), /原请求或检查点不符/) +}) + + +function measurementReceipt(patch = {}) { + return { environment: 'TEST', batch: input.batch, dispatchNo: 'DISPATCH-TEST', pallets: [{ warehousePalletId: '9', palletSpaces: 2, + weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1, + evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map(field => ({ field, actual: true, sourceType: 'WAREHOUSE_MEASUREMENT', + sourceBusinessNo: 'MEASURE-SAME-RUN', observedAt: '2026-10-19T10:00:00Z', value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }], ...patch } +} + +test('explicit fixed measurement file can be supplied after picking and continued without changing the original input', async () => { + const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-')) + try { + const file = join(directory, 'actual.json'), f = fixture({ missingActual: true }) + f.input = { ...f.input, loadingMeasurementFile: file } + await assert.rejects(runOverseasStage(f), /等待真实测量/) + assert.equal(f.checkpoint.state.pickingVerified, true) + assert.equal(f.checkpoint.state.commands['loading-complete'], undefined) + const picks = f.calls.filter(row => row.path.endsWith('/pick')).length + await writeFile(file, JSON.stringify(measurementReceipt()), 'utf8') + assert.equal((await runOverseasStage(f)).status, 'OVERSEAS_OUTBOUNDED') + assert.equal(f.calls.filter(row => row.path.endsWith('/pick')).length, picks) + const proof = f.checkpoint.state.loadingMeasurementEvidence + assert.equal(proof.source, 'EXPLICIT_FILE'); assert.equal(proof.file, file) + assert.equal(proof.sourceSha256, createHash('sha256').update(await readFile(file)).digest('hex')) + assert.deepEqual(proof.receipt, measurementReceipt()) + assert.equal(proof.measurements[0].palletSpaces, 2) + const original = structuredClone(f.checkpoint.state.commands['loading-complete'].request), writes = f.calls.filter(row => row.method !== 'GET').length + await writeFile(file, JSON.stringify(measurementReceipt({ batch: 'OTHER-BATCH', pallets: [] })), 'utf8') + await runOverseasStage(f) + assert.deepEqual(f.checkpoint.state.commands['loading-complete'].request, original) + assert.deepEqual(f.checkpoint.state.loadingMeasurementEvidence, proof) + assert.equal(f.calls.filter(row => row.method !== 'GET').length, writes + 1) // only the existing read-only arrival lookup + } finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) } +}) + +test('actual file environment, batch, dispatch, pallet and field evidence are checked before any loading command', async () => { + const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-scope-')) + try { + const file = join(directory, 'actual.json') + for (const alter of [receipt => { receipt.environment = 'GRAY' }, receipt => { receipt.batch = 'OTHER-BATCH' }, + receipt => { receipt.dispatchNo = 'OTHER-DISPATCH' }, receipt => { receipt.pallets[0].warehousePalletId = '999' }, + receipt => { receipt.pallets[0].evidence[0].actual = false }]) { + const receipt = measurementReceipt(); alter(receipt) + await writeFile(file, JSON.stringify(receipt), 'utf8') + const f = fixture({ missingActual: true }); f.input = { ...f.input, loadingMeasurementFile: file } + await assert.rejects(runOverseasStage(f), /绑定不符|其他托盘|actual来源证据/) + assert.equal(f.calls.filter(row => /loading-complete|transfer-delivery|\/depart$/.test(row.path)).length, 0) + assert.equal(f.checkpoint.state.commands['loading-complete'], undefined) + } + } finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) } +}) + +test('measurement input rejects ambiguous or relative paths before HTTP', () => { + assert.throws(() => validateOverseasInput({ ...input, loadingMeasurementFile: 'relative.json' }, domestic), /绝对路径/) + assert.throws(() => validateOverseasInput({ ...input, loadingMeasurementFile: join(tmpdir(), 'actual.json'), loadingMeasurements: measurementReceipt() }, domestic), /不能同时提供/) +}) + + +test('changed file after an unknown loading write cannot replace frozen original evidence or trigger resend', async () => { + const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-unknown-')) + try { + const file = join(directory, 'actual.json'), f = fixture({ missingActual: true }) + f.input = { ...f.input, loadingMeasurementFile: file } + await writeFile(file, JSON.stringify(measurementReceipt()), 'utf8') + const originalRequest = f.context.request + f.context.request = async (...args) => { + const result = await originalRequest(...args) + if (args[0] === 'POST' && args[1].endsWith('/loading-complete')) throw Error('loading response lost') + return result + } + await assert.rejects(runOverseasStage(f), /loading response lost/) + assert.equal(f.checkpoint.state.commands['loading-complete'].status, 'INTENT') + const proof = structuredClone(f.checkpoint.state.loadingMeasurementEvidence), original = structuredClone(f.checkpoint.state.commands['loading-complete'].request) + const edited = measurementReceipt(); edited.pallets[0].palletSpaces = 3; edited.pallets[0].evidence[0].value = 3 + await writeFile(file, JSON.stringify(edited), 'utf8') + const calls = f.calls.length + await assert.rejects(runOverseasStage(f), /结果未知/) + assert.equal(f.calls.length, calls) + assert.deepEqual(f.checkpoint.state.loadingMeasurementEvidence, proof) + assert.deepEqual(f.checkpoint.state.commands['loading-complete'].request, original) + } finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) } +}) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 1dda4fdbf..40f839f8b 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -57,6 +57,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '出车实测凭证按平台/车次独立归属;上传无远端幂等键,当前/历史版本引用经Lastleg校验后读取,不自动重试上传'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '排车车辆及司机证据通过File内部令牌入口按平台0、证据类别和排车ID复合核验;POST上传无远端幂等键,禁止自动重试,GET元数据及有界内容只读,不借文件上传推进排车状态'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', '原运费覆盖只读:按实际客户与头程入仓ID批量读取Finance已接受来源;目标端只查本库,无下游调用;不提供费用写入或资金命令'), diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 6675777cd..4ce1d3a59 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -123,7 +123,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 28) + assert.equal(reviewed.length, 29) assert.deepEqual(discovered.sort(), reviewed) })